feat: add configurable verification and redemption codes
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user