feat: add configurable verification and redemption codes

This commit is contained in:
237899745
2026-07-25 13:57:39 +08:00
parent d1f093685d
commit c6e96464d3
32 changed files with 2108 additions and 261 deletions

View File

@@ -8,9 +8,12 @@ import {
getUsage,
listInvoices,
listPlans,
listUserRedemptions,
redeemCode,
type InvoiceView,
type PlanView,
type SubscriptionView,
type UserRedemptionView,
type UsageResponse,
} from '@/services/api'
import { ApiError } from '@/services/http'
@@ -25,22 +28,31 @@ const plans = ref<PlanView[]>([])
const subscription = ref<SubscriptionView | null>(null)
const usage = ref<UsageResponse | null>(null)
const invoices = ref<InvoiceView[]>([])
const redemptions = ref<UserRedemptionView[]>([])
const busy = ref(false)
const redemptionCode = ref('')
const redemptionBusy = ref(false)
const redemptionMessage = ref<string | null>(null)
const redemptionError = ref<string | null>(null)
onMounted(async () => {
async function loadAll() {
if (!auth.token) return
loading.value = true
error.value = null
try {
const [p, s, u, inv] = await Promise.all([
const [p, s, u, inv, redeemed] = await Promise.all([
listPlans(),
getSubscription(auth.token),
getUsage(auth.token),
listInvoices(auth.token),
listUserRedemptions(auth.token),
])
plans.value = p.plans
subscription.value = s.subscription
usage.value = u
invoices.value = inv.invoices
redemptions.value = redeemed.redemptions
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
@@ -50,7 +62,37 @@ onMounted(async () => {
} finally {
loading.value = false
}
})
}
onMounted(loadAll)
async function submitRedemption() {
if (!auth.token || !redemptionCode.value.trim()) return
redemptionBusy.value = true
redemptionMessage.value = null
redemptionError.value = null
try {
const resp = await redeemCode(auth.token, redemptionCode.value.trim())
redemptionMessage.value = `${resp.message},有效至 ${new Date(resp.benefit_expires_at).toLocaleString()}`
redemptionCode.value = ''
const [s, u, redeemed] = await Promise.all([
getSubscription(auth.token),
getUsage(auth.token),
listUserRedemptions(auth.token),
])
subscription.value = s.subscription
usage.value = u
redemptions.value = redeemed.redemptions
} catch (err) {
if (err instanceof ApiError) {
redemptionError.value = `[${err.code}] ${err.message}`
} else {
redemptionError.value = '兑换失败,请稍后再试'
}
} finally {
redemptionBusy.value = false
}
}
async function openCheckout(planId: string) {
if (!auth.token) return
@@ -117,6 +159,9 @@ async function openPortal() {
<div v-if="(usage?.bonus_units ?? 0) > 0" class="mt-1 text-xs text-slate-500">
套餐额度 {{ usage?.included_units ?? 0 }} + 赠送 {{ usage?.bonus_units ?? 0 }}
</div>
<div v-if="(usage?.redeemed_units ?? 0) > 0" class="mt-1 text-xs text-slate-500">
另有 {{ usage?.redeemed_units ?? 0 }} 次限时兑换额度
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-xs font-medium text-slate-500">周期</div>
@@ -138,6 +183,43 @@ async function openPortal() {
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-sm font-medium text-slate-900">兑换套餐或次数</div>
<p class="mt-1 text-xs text-slate-500">输入管理员发放的兑换码次数卡会按最早到期顺序使用</p>
<form class="mt-3 flex flex-col gap-2 sm:flex-row" @submit.prevent="submitRedemption">
<input
v-model="redemptionCode"
type="text"
autocomplete="off"
spellcheck="false"
class="min-w-0 flex-1 rounded-md border border-slate-200 bg-white px-3 py-2 font-mono text-sm uppercase text-slate-800"
placeholder="IMG-XXXX-XXXX-XXXX-XXXX"
required
/>
<button type="submit" class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50" :disabled="redemptionBusy">
{{ redemptionBusy ? '兑换中…' : '立即兑换' }}
</button>
</form>
<div v-if="redemptionMessage" class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">{{ redemptionMessage }}</div>
<div v-if="redemptionError" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">{{ redemptionError }}</div>
<div v-if="redemptions.length > 0" class="mt-4 overflow-auto">
<table class="min-w-full text-left text-sm">
<thead class="text-xs text-slate-500">
<tr><th class="py-2 pr-4">兑换码</th><th class="py-2 pr-4">权益</th><th class="py-2 pr-4">剩余</th><th class="py-2 pr-4">有效期</th></tr>
</thead>
<tbody class="text-slate-700">
<tr v-for="item in redemptions" :key="item.id" class="border-t border-slate-100">
<td class="py-2 pr-4 font-mono text-xs">{{ item.code_hint }}</td>
<td class="py-2 pr-4">{{ item.benefit_kind === 'plan' ? item.plan_name : `${item.units ?? 0}` }}</td>
<td class="py-2 pr-4">{{ item.benefit_kind === 'units' ? `${item.remaining_units ?? 0}` : '—' }}</td>
<td class="py-2 pr-4">{{ new Date(item.benefit_expires_at).toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-sm font-medium text-slate-900">充值额度 / 购买套餐</div>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3">

View File

@@ -2,7 +2,7 @@
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getSubscription, getUsage, sendVerification } from '@/services/api'
import { getProfile, getSubscription, getUsage, sendVerification } from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
@@ -21,12 +21,20 @@ const sendingVerification = ref(false)
onMounted(async () => {
if (!auth.token) return
try {
const [u, s] = await Promise.all([getUsage(auth.token), getSubscription(auth.token)])
const [u, s, profile] = await Promise.all([
getUsage(auth.token),
getSubscription(auth.token),
getProfile(auth.token),
])
usage.value = u
subscription.value = s.subscription
auth.updateUser(profile)
if (route.query.welcome === '1') {
alert.value = { type: 'success', message: '欢迎加入 ImageForge请尽快完成邮箱验证。' }
alert.value = {
type: 'success',
message: profile.email_verified ? '欢迎加入 ImageForge' : '欢迎加入 ImageForge请尽快完成邮箱验证。',
}
}
} catch (err) {
if (err instanceof ApiError) {
@@ -45,6 +53,7 @@ async function resendVerification() {
alert.value = null
try {
const resp = await sendVerification(auth.token)
auth.updateUser(await getProfile(auth.token))
alert.value = { type: 'success', message: resp.message }
} catch (err) {
if (err instanceof ApiError) {

View File

@@ -56,6 +56,7 @@ async function resendVerification() {
verificationError.value = null
try {
const resp = await sendVerification(auth.token)
auth.updateUser(await getProfile(auth.token))
verificationMessage.value = resp.message
} catch (err) {
if (err instanceof ApiError) {