feat(ECR-015): RedemptionCode 兑换码并 Closed

批次生成/作废、C 端兑码延长会员;admin-h5 /codes。
Loop continuous。Next:ECR-016 UserIntelligence。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 18:16:13 +08:00
co-authored by Cursor
parent 0e26aabef8
commit 1eeb0b00e7
33 changed files with 1073 additions and 36 deletions
+16
View File
@@ -158,6 +158,22 @@ export const adminApi = {
amount_cents: number
active: boolean
}>('PUT', `/membership-plans/${code}`, body),
createRedemptionBatch: (body: { label: string; plan_code: string; quantity: number }) =>
request<{
batch: { id: string; label: string; plan_code: string; quantity: number }
codes: Array<{ id: string; code: string; status: string }>
}>('POST', '/redemption-batches', body),
redemptionBatches: () =>
request<{ items: Array<{ id: string; label: string; plan_code: string; quantity: number; created_at: string }> }>(
'GET',
'/redemption-batches',
),
redemptionCodes: (batchId: string) =>
request<{ items: Array<{ id: string; code: string; status: string; plan_code: string }> }>(
'GET',
`/redemption-batches/${batchId}/codes`,
),
disableRedemptionCode: (id: string) => request<{ ok: boolean }>('POST', `/redemption-codes/${id}/disable`),
orders: () =>
request<{
items: Array<{
+1
View File
@@ -29,6 +29,7 @@ async function onLogout() {
<RouterLink to="/content">内容</RouterLink>
<RouterLink to="/users">用户</RouterLink>
<RouterLink to="/plans">套餐</RouterLink>
<RouterLink to="/codes">兑换码</RouterLink>
<RouterLink to="/orders">订单</RouterLink>
<RouterLink to="/audit">审计</RouterLink>
</nav>
+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const loading = ref(false)
const error = ref('')
const msg = ref('')
const label = ref('batch')
const plan = ref('month')
const qty = ref(5)
const batches = ref<Array<{ id: string; label: string; plan_code: string; quantity: number; created_at: string }>>([])
const codes = ref<Array<{ id: string; code: string; status: string }>>([])
const activeBatch = ref('')
async function loadBatches() {
loading.value = true
error.value = ''
try {
const res = await adminApi.redemptionBatches()
batches.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function createBatch() {
msg.value = ''
try {
const res = await adminApi.createRedemptionBatch({
label: label.value,
plan_code: plan.value,
quantity: Number(qty.value),
})
msg.value = `已生成 ${res.codes.length} 个码`
activeBatch.value = res.batch.id
codes.value = res.codes
await loadBatches()
} catch (e) {
msg.value = e instanceof Error ? e.message : '生成失败'
}
}
async function openBatch(id: string) {
activeBatch.value = id
const res = await adminApi.redemptionCodes(id)
codes.value = res.items || []
}
async function disable(id: string) {
await adminApi.disableRedemptionCode(id)
if (activeBatch.value) await openBatch(activeBatch.value)
}
onMounted(loadBatches)
</script>
<template>
<section>
<h1>兑换码</h1>
<p class="muted">批量生成会员兑换码禁真支付</p>
<p v-if="error" class="err">{{ error }}</p>
<p v-if="msg" class="muted">{{ msg }}</p>
<div v-if="auth.can('admin.membership.codes.write')" class="card block">
<h2>生成批次</h2>
<div class="row">
<label>标签 <input v-model="label" /></label>
<label>套餐
<select v-model="plan">
<option value="month">month</option>
<option value="quarter">quarter</option>
<option value="year">year</option>
</select>
</label>
<label>数量 <input v-model.number="qty" type="number" min="1" max="100" /></label>
<button class="btn" type="button" @click="createBatch">生成</button>
</div>
</div>
<div class="card block">
<h2>批次</h2>
<p v-if="loading" class="muted">加载中</p>
<ul v-else>
<li v-for="b in batches" :key="b.id">
<button class="link" type="button" @click="openBatch(b.id)">
{{ b.label }} · {{ b.plan_code }} × {{ b.quantity }}
</button>
</li>
</ul>
</div>
<div v-if="codes.length" class="card block">
<h2>码列表</h2>
<table>
<thead><tr><th></th><th>状态</th><th></th></tr></thead>
<tbody>
<tr v-for="c in codes" :key="c.id">
<td><code>{{ c.code }}</code></td>
<td>{{ c.status }}</td>
<td>
<button
v-if="c.status === 'unused' && auth.can('admin.membership.codes.write')"
class="btn ghost"
type="button"
@click="disable(c.id)"
>
作废
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.block { margin-bottom: 1rem; }
.row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: var(--muted); }
input, select { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; }
.link { background: none; border: 0; color: var(--accent); cursor: pointer; padding: 0.2rem 0; }
ul { margin: 0; padding-left: 1rem; }
code { font-size: 0.85rem; }
</style>
+1
View File
@@ -16,6 +16,7 @@ const router = createRouter({
{ path: 'users/:id', name: 'user', component: () => import('@/pages/UserDetailPage.vue') },
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') },
{ path: 'plans', name: 'plans', component: () => import('@/pages/MembershipPlansPage.vue') },
{ path: 'codes', name: 'codes', component: () => import('@/pages/RedemptionPage.vue') },
{ path: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
],
},