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

@@ -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) {

View File

@@ -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 }}

View File

@@ -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>

View File

@@ -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">

View 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>

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) {