feat(ECR-009): Ops-D order filters, plan display prices, refund status

LOOP-RUN-002 sample: admin order multi-filter, membership display
price catalog, read-only refund_status. No real payment or RBAC.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 02:45:32 +08:00
co-authored by Cursor
parent 7ab9add5dd
commit ca3b13554c
37 changed files with 2169 additions and 25 deletions
+29 -3
View File
@@ -112,8 +112,25 @@ export const adminApi = {
request<{ ok: boolean; ask_paid_quota_left: number }>('POST', `/users/${id}/ask-quota/grant`, {
delta,
}),
orders: () =>
request<{
orders: (params?: {
status?: string
kind?: string
from?: string
to?: string
user_id?: string
limit?: number
offset?: number
}) => {
const q = new URLSearchParams()
if (params?.status) q.set('status', params.status)
if (params?.kind) q.set('kind', params.kind)
if (params?.from) q.set('from', params.from)
if (params?.to) q.set('to', params.to)
if (params?.user_id) q.set('user_id', params.user_id)
if (params?.limit != null) q.set('limit', String(params.limit))
if (params?.offset != null) q.set('offset', String(params.offset))
const qs = q.toString()
return request<{
items: Array<{
id: string
user_id: string
@@ -121,9 +138,18 @@ export const adminApi = {
plan?: string
amount_cents: number
status: string
refund_status: string
created_at: string
}>
}>('GET', '/orders'),
}>('GET', `/orders${qs ? `?${qs}` : ''}`)
},
planPrices: () =>
request<{ items: Array<{ plan: string; display_cents: number; updated_at: string }> }>(
'GET',
'/membership/plan-prices',
),
savePlanPrices: (items: Array<{ plan: string; display_cents: number }>) =>
request<{ ok: boolean }>('PUT', '/membership/plan-prices', { items }),
audit: () =>
request<{
items: Array<{
+1
View File
@@ -29,6 +29,7 @@ async function onLogout() {
<RouterLink to="/content">内容</RouterLink>
<RouterLink to="/users">用户</RouterLink>
<RouterLink to="/orders">订单</RouterLink>
<RouterLink to="/pricing">定价</RouterLink>
<RouterLink to="/audit">审计</RouterLink>
</nav>
<div class="foot">
+29 -2
View File
@@ -4,6 +4,11 @@ import { adminApi } from '@/api/client'
const loading = ref(false)
const error = ref('')
const status = ref('')
const kind = ref('')
const from = ref('')
const to = ref('')
const userId = ref('')
const items = ref<
Array<{
id: string
@@ -12,6 +17,7 @@ const items = ref<
plan?: string
amount_cents: number
status: string
refund_status: string
created_at: string
}>
>([])
@@ -20,7 +26,13 @@ async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.orders()
const res = await adminApi.orders({
status: status.value || undefined,
kind: kind.value || undefined,
from: from.value || undefined,
to: to.value || undefined,
user_id: userId.value || undefined,
})
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
@@ -35,13 +47,23 @@ onMounted(load)
<template>
<section>
<h1>订单</h1>
<div class="filters card">
<label>状态 <input v-model="status" placeholder="paid / pending" /></label>
<label>类型 <input v-model="kind" placeholder="membership" /></label>
<label> <input v-model="from" type="date" /></label>
<label> <input v-model="to" type="date" /></label>
<label>用户 ID <input v-model="userId" class="wide" /></label>
<button class="btn" type="button" @click="load">筛选</button>
</div>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="card">
<p v-if="!items.length" class="muted">暂无订单</p>
<table v-else>
<thead>
<tr><th>ID</th><th>用户</th><th>类型</th><th>状态</th><th>金额</th><th>时间</th></tr>
<tr>
<th>ID</th><th>用户</th><th>类型</th><th>状态</th><th>退款</th><th>金额</th><th>时间</th>
</tr>
</thead>
<tbody>
<tr v-for="o in items" :key="o.id">
@@ -49,6 +71,7 @@ onMounted(load)
<td>{{ o.user_id }}</td>
<td>{{ o.kind }} {{ o.plan || '' }}</td>
<td>{{ o.status }}</td>
<td>{{ o.refund_status }}</td>
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
<td>{{ o.created_at }}</td>
</tr>
@@ -60,4 +83,8 @@ onMounted(load)
<style scoped>
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
.filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; margin-bottom: 1rem; }
.filters label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; }
.filters input { min-width: 8rem; }
.filters input.wide { min-width: 16rem; }
</style>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const ok = ref('')
const items = ref<Array<{ plan: string; display_cents: number; updated_at?: string }>>([])
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.planPrices()
items.value = (res.items || []).map((i) => ({ ...i }))
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
error.value = ''
ok.value = ''
try {
await adminApi.savePlanPrices(
items.value.map((i) => ({ plan: i.plan, display_cents: Number(i.display_cents) })),
)
ok.value = '已保存展示价(不影响历史订单金额)'
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<section>
<h1>套餐展示价</h1>
<p class="muted">仅目录展示价不改已支付订单的 amount_cents</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<div v-if="!loading && !error" class="card">
<table>
<thead>
<tr><th>套餐</th><th>展示价</th><th>更新时间</th></tr>
</thead>
<tbody>
<tr v-for="row in items" :key="row.plan">
<td>{{ row.plan }}</td>
<td>
<input v-model.number="row.display_cents" type="number" min="0" />
</td>
<td class="muted">{{ row.updated_at || '—' }}</td>
</tr>
</tbody>
</table>
<button class="btn" type="button" :disabled="saving" @click="save">
{{ saving ? '保存中' : '保存' }}
</button>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.5rem; font-size: 1.35rem; }
.ok { color: #1a7f37; }
input[type='number'] { width: 8rem; }
.btn { margin-top: 1rem; }
</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: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue') },
{ path: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
],
},