feat(ECR-014): MembershipPlan 套餐配置并 Closed

membership_plans 表、admin 套餐页、Grant/CreateOrder 读表;
Loop continuous 自动 Approve/Closed。Next:ECR-015 RedemptionCode。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 18:03:38 +08:00
co-authored by Cursor
parent e25cd94b0c
commit 882c01d81a
31 changed files with 887 additions and 27 deletions
+22
View File
@@ -136,6 +136,28 @@ export const adminApi = {
request<{ ok: boolean; ask_paid_quota_left: number }>('POST', `/users/${id}/ask-quota/grant`, {
delta,
}),
membershipPlans: () =>
request<{
items: Array<{
code: string
title: string
duration_days: number
amount_cents: number
active: boolean
updated_at: string
}>
}>('GET', '/membership-plans'),
updateMembershipPlan: (
code: string,
body: { title: string; duration_days: number; amount_cents: number; active: boolean },
) =>
request<{
code: string
title: string
duration_days: number
amount_cents: number
active: boolean
}>('PUT', `/membership-plans/${code}`, body),
orders: () =>
request<{
items: Array<{
+1
View File
@@ -28,6 +28,7 @@ async function onLogout() {
<RouterLink to="/analytics">数据</RouterLink>
<RouterLink to="/content">内容</RouterLink>
<RouterLink to="/users">用户</RouterLink>
<RouterLink to="/plans">套餐</RouterLink>
<RouterLink to="/orders">订单</RouterLink>
<RouterLink to="/audit">审计</RouterLink>
</nav>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
type Plan = {
code: string
title: string
duration_days: number
amount_cents: number
active: boolean
}
const auth = useAuthStore()
const loading = ref(false)
const error = ref('')
const msg = ref('')
const items = ref<Plan[]>([])
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.membershipPlans()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function save(p: Plan) {
msg.value = ''
try {
await adminApi.updateMembershipPlan(p.code, {
title: p.title,
duration_days: Number(p.duration_days),
amount_cents: Number(p.amount_cents),
active: p.active,
})
msg.value = `${p.code} 已保存`
await load()
} catch (e) {
msg.value = e instanceof Error ? e.message : '保存失败'
}
}
onMounted(load)
</script>
<template>
<section>
<h1>会员套餐</h1>
<p class="muted">配置成长会员时长与标价mock 履约读表</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<p v-if="msg" class="muted">{{ msg }}</p>
<div v-for="p in items" :key="p.code" class="card block">
<h2>{{ p.code }}</h2>
<div class="row">
<label>标题 <input v-model="p.title" /></label>
<label>天数 <input v-model.number="p.duration_days" type="number" min="1" /></label>
<label>标价 <input v-model.number="p.amount_cents" type="number" min="0" /></label>
<label class="chk"><input v-model="p.active" type="checkbox" /> 启用</label>
<button
v-if="auth.can('admin.membership.plans.write')"
class="btn"
type="button"
@click="save(p)"
>
保存
</button>
</div>
</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; text-transform: uppercase; }
.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 { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; min-width: 6rem; }
.chk { flex-direction: row; align-items: center; gap: 0.35rem; padding-bottom: 0.4rem; }
</style>
+1
View File
@@ -15,6 +15,7 @@ const router = createRouter({
{ path: 'users', name: 'users', component: () => import('@/pages/UsersPage.vue') },
{ 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: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
],
},