feat: add configurable verification and redemption codes
This commit is contained in:
@@ -5,6 +5,7 @@ import { zipSync } from 'fflate'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
compressFile,
|
||||
getProfile,
|
||||
getSubscription,
|
||||
getUsage,
|
||||
sendVerification,
|
||||
@@ -62,9 +63,14 @@ onMounted(async () => {
|
||||
quotaLoading.value = true
|
||||
quotaError.value = null
|
||||
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)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
quotaError.value = `[${err.code}] ${err.message}`
|
||||
@@ -316,6 +322,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) {
|
||||
|
||||
@@ -38,7 +38,7 @@ async function submit() {
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h1 class="text-xl font-semibold text-slate-900">注册</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">注册后必须验证邮箱才能使用登录态压缩与 API 能力。</p>
|
||||
<p class="mt-1 text-sm text-slate-600">邮箱是否需要验证由管理员设置,注册后会自动提示。</p>
|
||||
|
||||
<div v-if="error" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
|
||||
{{ error }}
|
||||
|
||||
@@ -280,7 +280,8 @@ onMounted(async () => {
|
||||
<div v-if="creditResult" class="mt-2 text-xs text-slate-500">
|
||||
当前周期:{{ new Date(creditResult.period_start).toLocaleString() }} →
|
||||
{{ new Date(creditResult.period_end).toLocaleString() }},已用 {{ creditResult.used_units }} /
|
||||
{{ creditResult.total_units }}(含赠送 {{ creditResult.bonus_units }}),剩余 {{ creditResult.remaining_units }}
|
||||
{{ creditResult.total_units }}(含赠送 {{ creditResult.bonus_units }}、兑换 {{ creditResult.redeemed_units }}),剩余
|
||||
{{ creditResult.remaining_units }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
getAuthConfig,
|
||||
getMailConfig,
|
||||
getStripeConfig,
|
||||
listAdminPlans,
|
||||
sendMailTest,
|
||||
updateAdminPlan,
|
||||
updateAuthConfig,
|
||||
updateMailConfig,
|
||||
updateStripeConfig,
|
||||
type AdminMailConfig,
|
||||
@@ -50,6 +52,11 @@ const mailBusy = ref(false)
|
||||
const mailMessage = ref<string | null>(null)
|
||||
const mailError = ref<string | null>(null)
|
||||
|
||||
const emailVerificationRequired = ref(true)
|
||||
const authBusy = ref(false)
|
||||
const authMessage = ref<string | null>(null)
|
||||
const authError = ref<string | null>(null)
|
||||
|
||||
const testEmail = ref('')
|
||||
const testBusy = ref(false)
|
||||
const testMessage = ref<string | null>(null)
|
||||
@@ -99,12 +106,14 @@ async function loadAll() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [stripe, mailCfg, planResp] = await Promise.all([
|
||||
const [stripe, authCfg, mailCfg, planResp] = await Promise.all([
|
||||
getStripeConfig(auth.token),
|
||||
getAuthConfig(auth.token),
|
||||
getMailConfig(auth.token),
|
||||
listAdminPlans(auth.token),
|
||||
])
|
||||
stripeConfig.value = stripe
|
||||
emailVerificationRequired.value = authCfg.email_verification_required
|
||||
mailConfig.value = mailCfg
|
||||
applyMailConfig(mailCfg)
|
||||
plans.value = planResp.plans
|
||||
@@ -119,6 +128,26 @@ async function loadAll() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAuthConfig() {
|
||||
if (!auth.token) return
|
||||
authBusy.value = true
|
||||
authMessage.value = null
|
||||
authError.value = null
|
||||
try {
|
||||
const resp = await updateAuthConfig(auth.token, emailVerificationRequired.value)
|
||||
emailVerificationRequired.value = resp.email_verification_required
|
||||
authMessage.value = '邮箱验证策略已保存并立即生效'
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
authError.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
authError.value = '更新失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
authBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStripe() {
|
||||
if (!auth.token) return
|
||||
stripeBusy.value = true
|
||||
@@ -335,6 +364,22 @@ onMounted(loadAll)
|
||||
</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">此开关独立于 SMTP 配置,保存后立即影响注册、修改邮箱、压缩与 API Key 权限。</p>
|
||||
<label class="mt-3 flex items-center gap-2 text-sm text-slate-700">
|
||||
<input v-model="emailVerificationRequired" type="checkbox" class="h-4 w-4 rounded border-slate-300" />
|
||||
注册及更换邮箱后必须验证
|
||||
</label>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
<button type="button" class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50" :disabled="authBusy" @click="saveAuthConfig">
|
||||
{{ authBusy ? '保存中…' : '保存验证策略' }}
|
||||
</button>
|
||||
<div v-if="authMessage" class="text-xs text-emerald-700">{{ authMessage }}</div>
|
||||
<div v-if="authError" class="text-xs text-rose-700">{{ authError }}</div>
|
||||
</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-2">
|
||||
|
||||
254
frontend/src/pages/admin/AdminRedemptionPage.vue
Normal file
254
frontend/src/pages/admin/AdminRedemptionPage.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
createAdminRedemptionCodes,
|
||||
listAdminPlans,
|
||||
listAdminRedemptionCodes,
|
||||
updateAdminRedemptionCode,
|
||||
type AdminPlanView,
|
||||
type AdminRedemptionCodeView,
|
||||
type GeneratedRedemptionCode,
|
||||
type RedemptionKind,
|
||||
} from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const plans = ref<AdminPlanView[]>([])
|
||||
const codes = ref<AdminRedemptionCodeView[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const limit = 50
|
||||
|
||||
const form = ref({
|
||||
benefit_kind: 'plan' as RedemptionKind,
|
||||
plan_id: '',
|
||||
units: 100,
|
||||
duration_days: 30,
|
||||
redeem_before: '',
|
||||
quantity: 1,
|
||||
note: '',
|
||||
})
|
||||
const generating = ref(false)
|
||||
const generated = ref<GeneratedRedemptionCode[]>([])
|
||||
const generateMessage = ref<string | null>(null)
|
||||
const generateError = ref<string | null>(null)
|
||||
const updatingId = ref<string | null>(null)
|
||||
const copyMessage = ref<string | null>(null)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit)))
|
||||
const generatedText = computed(() => generated.value.map((item) => item.code).join('\n'))
|
||||
|
||||
function errorText(err: unknown, fallback: string) {
|
||||
return err instanceof ApiError ? `[${err.code}] ${err.message}` : fallback
|
||||
}
|
||||
|
||||
async function load(targetPage = page.value) {
|
||||
if (!auth.token) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [planResp, codeResp] = await Promise.all([
|
||||
listAdminPlans(auth.token),
|
||||
listAdminRedemptionCodes(auth.token, targetPage, limit),
|
||||
])
|
||||
plans.value = planResp.plans.filter((plan) => plan.is_active)
|
||||
codes.value = codeResp.codes
|
||||
total.value = codeResp.total
|
||||
page.value = codeResp.page
|
||||
if (!form.value.plan_id && plans.value.length > 0) {
|
||||
form.value.plan_id = plans.value[0]?.id ?? ''
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = errorText(err, '加载兑换码失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
if (!auth.token) return
|
||||
generating.value = true
|
||||
generated.value = []
|
||||
generateMessage.value = null
|
||||
generateError.value = null
|
||||
copyMessage.value = null
|
||||
try {
|
||||
const payload: {
|
||||
benefit_kind: RedemptionKind
|
||||
plan_id?: string
|
||||
units?: number
|
||||
duration_days: number
|
||||
redeem_before?: string
|
||||
quantity: number
|
||||
note?: string
|
||||
} = {
|
||||
benefit_kind: form.value.benefit_kind,
|
||||
duration_days: form.value.duration_days,
|
||||
quantity: form.value.quantity,
|
||||
}
|
||||
if (form.value.benefit_kind === 'plan') payload.plan_id = form.value.plan_id
|
||||
if (form.value.benefit_kind === 'units') payload.units = form.value.units
|
||||
if (form.value.redeem_before) payload.redeem_before = new Date(form.value.redeem_before).toISOString()
|
||||
if (form.value.note.trim()) payload.note = form.value.note.trim()
|
||||
|
||||
const resp = await createAdminRedemptionCodes(auth.token, payload)
|
||||
generated.value = resp.codes
|
||||
generateMessage.value = resp.message
|
||||
await load(1)
|
||||
} catch (err) {
|
||||
generateError.value = errorText(err, '生成兑换码失败')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyGenerated() {
|
||||
if (!generatedText.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedText.value)
|
||||
copyMessage.value = '已复制全部兑换码'
|
||||
} catch {
|
||||
copyMessage.value = '自动复制失败,请手动选择文本'
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCode(item: AdminRedemptionCodeView) {
|
||||
if (!auth.token || item.redeemed_at) return
|
||||
updatingId.value = item.id
|
||||
error.value = null
|
||||
try {
|
||||
await updateAdminRedemptionCode(auth.token, item.id, !item.is_active)
|
||||
item.is_active = !item.is_active
|
||||
} catch (err) {
|
||||
error.value = errorText(err, '更新兑换码失败')
|
||||
} finally {
|
||||
updatingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(item: AdminRedemptionCodeView) {
|
||||
if (item.redeemed_at) return '已兑换'
|
||||
if (!item.is_active) return '已停用'
|
||||
if (item.redeem_before && new Date(item.redeem_before).getTime() <= Date.now()) return '已过期'
|
||||
return '待兑换'
|
||||
}
|
||||
|
||||
function benefitLabel(item: AdminRedemptionCodeView) {
|
||||
return item.benefit_kind === 'plan' ? item.plan_name ?? '未知套餐' : `${item.units ?? 0} 次`
|
||||
}
|
||||
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-lg font-semibold text-slate-900">兑换码</h2>
|
||||
<p class="text-sm text-slate-600">生成套餐卡或限时次数卡。完整兑换码只在生成后显示一次。</p>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
|
||||
{{ error }}
|
||||
</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-4 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">卡类型</div>
|
||||
<select v-model="form.benefit_kind" class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm">
|
||||
<option value="plan">套餐卡</option>
|
||||
<option value="units">次数卡</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.benefit_kind === 'plan'" class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">对应套餐</div>
|
||||
<select v-model="form.plan_id" class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm" required>
|
||||
<option v-for="plan in plans" :key="plan.id" :value="plan.id">{{ plan.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-else class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">可用次数</div>
|
||||
<input v-model.number="form.units" type="number" min="1" max="10000000" class="w-full rounded-md border border-slate-200 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">权益有效天数</div>
|
||||
<input v-model.number="form.duration_days" type="number" min="1" max="3650" class="w-full rounded-md border border-slate-200 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">生成数量</div>
|
||||
<input v-model.number="form.quantity" type="number" min="1" max="200" class="w-full rounded-md border border-slate-200 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">兑换截止时间(可选)</div>
|
||||
<input v-model="form.redeem_before" type="datetime-local" class="w-full rounded-md border border-slate-200 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="space-y-1 md:col-span-3">
|
||||
<div class="text-xs font-medium text-slate-600">备注(可选)</div>
|
||||
<input v-model="form.note" type="text" maxlength="500" class="w-full rounded-md border border-slate-200 px-3 py-2 text-sm" placeholder="例如:活动批次、渠道或用途" />
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="mt-4 rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50" :disabled="generating" @click="generate">
|
||||
{{ generating ? '生成中…' : '生成兑换码' }}
|
||||
</button>
|
||||
<div v-if="generateError" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">{{ generateError }}</div>
|
||||
|
||||
<div v-if="generated.length > 0" class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-amber-950">{{ generateMessage }}</div>
|
||||
<div class="text-xs text-amber-800">离开或刷新页面后无法再次查看完整兑换码。</div>
|
||||
</div>
|
||||
<button type="button" class="rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs text-amber-900" @click="copyGenerated">复制全部</button>
|
||||
</div>
|
||||
<textarea :value="generatedText" readonly class="mt-3 h-36 w-full rounded-md border border-amber-200 bg-white px-3 py-2 font-mono text-sm text-slate-800"></textarea>
|
||||
<div v-if="copyMessage" class="mt-2 text-xs text-amber-900">{{ copyMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm font-medium text-slate-900">兑换码记录({{ total }})</div>
|
||||
<button type="button" class="text-xs text-indigo-600 hover:text-indigo-700" @click="load(page)">刷新</button>
|
||||
</div>
|
||||
<div v-if="loading" class="mt-4 text-sm text-slate-600">加载中…</div>
|
||||
<div v-else-if="codes.length === 0" class="mt-4 text-sm text-slate-600">暂无兑换码</div>
|
||||
<div v-else 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><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 codes" :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">{{ benefitLabel(item) }}</td>
|
||||
<td class="py-2 pr-4">{{ item.duration_days }} 天</td>
|
||||
<td class="py-2 pr-4">{{ statusLabel(item) }}</td>
|
||||
<td class="py-2 pr-4">{{ item.redeemed_username ?? '—' }}</td>
|
||||
<td class="py-2 pr-4">{{ item.redeem_before ? new Date(item.redeem_before).toLocaleString() : '不限' }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
<button v-if="!item.redeemed_at" type="button" class="text-xs text-indigo-600 disabled:opacity-50" :disabled="updatingId === item.id" @click="toggleCode(item)">
|
||||
{{ item.is_active ? '停用' : '启用' }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-slate-400">不可修改</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between text-xs text-slate-500">
|
||||
<button type="button" class="rounded border border-slate-200 px-3 py-1.5 disabled:opacity-40" :disabled="page <= 1 || loading" @click="load(page - 1)">上一页</button>
|
||||
<span>第 {{ page }} / {{ totalPages }} 页</span>
|
||||
<button type="button" class="rounded border border-slate-200 px-3 py-1.5 disabled:opacity-40" :disabled="page >= totalPages || loading" @click="load(page + 1)">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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