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:
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user