feat(ECR-006): 落地运营后台 Phase A(admin API + admin-h5)
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

新增独立鉴权的 /api/v1/admin 与 Vue 控制台;会员授予与审计同事务,并补集成/单测。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-06 18:35:53 +08:00
co-authored by Cursor
parent 4a583c9480
commit 879bf70cb7
59 changed files with 2462 additions and 20 deletions
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
const loading = ref(false)
const error = ref('')
const items = ref<
Array<{
id: string
admin_id: string
action: string
target_type: string
target_id: string
meta: unknown
created_at: string
}>
>([])
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.audit()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<section>
<h1>审计</h1>
<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>时间</th><th>动作</th><th>目标</th><th>管理员</th><th>meta</th></tr>
</thead>
<tbody>
<tr v-for="a in items" :key="a.id">
<td>{{ a.created_at }}</td>
<td>{{ a.action }}</td>
<td>{{ a.target_type }} {{ a.target_id }}</td>
<td>{{ a.admin_id }}</td>
<td><code>{{ JSON.stringify(a.meta) }}</code></td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
code { font-size: 0.8rem; }
</style>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const username = ref('admin')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function onSubmit() {
error.value = ''
loading.value = true
try {
await auth.login(username.value.trim(), password.value)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
await router.replace(redirect)
} catch (e) {
error.value = e instanceof Error ? e.message : '登录失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="wrap">
<form class="card login" @submit.prevent="onSubmit">
<h1>运营后台</h1>
<p class="muted">愈心谷内部工具 · Phase A</p>
<label class="field">
<span>用户名</span>
<input v-model="username" autocomplete="username" required />
</label>
<label class="field">
<span>密码</span>
<input v-model="password" type="password" autocomplete="current-password" required />
</label>
<p v-if="error" class="err">{{ error }}</p>
<button class="btn" type="submit" :disabled="loading">
{{ loading ? '登录中…' : '登录' }}
</button>
</form>
</div>
</template>
<style scoped>
.wrap { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem; }
.login { width: min(380px, 100%); }
h1 { margin: 0 0 0.25rem; font-size: 1.45rem; }
.muted { margin: 0 0 1.2rem; }
.btn { width: 100%; }
</style>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
const loading = ref(false)
const error = ref('')
const items = ref<
Array<{
id: string
user_id: string
kind: string
plan?: string
amount_cents: number
status: string
created_at: string
}>
>([])
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.orders()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<section>
<h1>订单</h1>
<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>
</thead>
<tbody>
<tr v-for="o in items" :key="o.id">
<td>{{ o.id }}</td>
<td>{{ o.user_id }}</td>
<td>{{ o.kind }} {{ o.plan || '' }}</td>
<td>{{ o.status }}</td>
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
<td>{{ o.created_at }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
</style>
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { adminApi, type UserDetail } from '@/api/client'
const route = useRoute()
const loading = ref(false)
const error = ref('')
const detail = ref<UserDetail | null>(null)
const plan = ref('month')
const grantMsg = ref('')
async function load() {
loading.value = true
error.value = ''
try {
detail.value = await adminApi.user(String(route.params.id))
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function grant() {
grantMsg.value = ''
try {
await adminApi.grant(String(route.params.id), plan.value)
grantMsg.value = '已授予'
await load()
} catch (e) {
grantMsg.value = e instanceof Error ? e.message : '授予失败'
}
}
onMounted(load)
</script>
<template>
<section>
<h1>用户详情</h1>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<template v-else-if="detail">
<div class="card block">
<p><strong>ID</strong> {{ detail.id }}</p>
<p><strong>状态</strong> {{ detail.status }}</p>
<p><strong>创建</strong> {{ detail.created_at }}</p>
</div>
<div class="card block">
<h2>成长会员</h2>
<p v-if="detail.membership">
{{ detail.membership.active ? '有效' : '无效' }} ·
{{ detail.membership.plan || '' }} ·
{{ detail.membership.status }} ·
到期 {{ detail.membership.expires_at || '' }}
</p>
<div class="grant">
<select v-model="plan">
<option value="month"></option>
<option value="quarter"></option>
<option value="year"></option>
</select>
<button class="btn" type="button" @click="grant">授予 / 延长</button>
<span v-if="grantMsg" class="muted">{{ grantMsg }}</span>
</div>
</div>
<div class="card block">
<h2>档案</h2>
<p v-if="!detail.profiles?.length" class="muted">无档案</p>
<ul v-else>
<li v-for="p in detail.profiles" :key="p.id">
{{ p.display_name || '未命名' }}{{ p.relation }}· {{ p.id }}
</li>
</ul>
</div>
<div class="card block">
<h2>近订单</h2>
<p v-if="!detail.recent_orders?.length" class="muted">无订单</p>
<table v-else>
<thead><tr><th>ID</th><th>类型</th><th>状态</th><th>金额</th></tr></thead>
<tbody>
<tr v-for="o in detail.recent_orders" :key="o.id">
<td>{{ o.id }}</td>
<td>{{ o.kind }} {{ o.plan || '' }}</td>
<td>{{ o.status }}</td>
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</section>
</template>
<style scoped>
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.block { margin-bottom: 1rem; }
.grant { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.75rem; }
.grant select { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; }
</style>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { adminApi } from '@/api/client'
const q = ref('')
const loading = ref(false)
const error = ref('')
const items = ref<Array<{ id: string; status: string; created_at: string }>>([])
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.users(q.value.trim())
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<section>
<header class="head">
<h1>用户</h1>
<form class="search" @submit.prevent="load">
<input v-model="q" placeholder="精确 user id (UUID)" />
<button class="btn" type="submit">查询</button>
</form>
</header>
<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></tr>
</thead>
<tbody>
<tr v-for="u in items" :key="u.id">
<td><RouterLink :to="`/users/${u.id}`">{{ u.id }}</RouterLink></td>
<td>{{ u.status }}</td>
<td>{{ u.created_at }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
.head { display: flex; flex-wrap: wrap; gap: 1rem; align-items: end; justify-content: space-between; margin-bottom: 1rem; }
h1 { margin: 0; font-size: 1.35rem; }
.search { display: flex; gap: 0.5rem; }
.search input { min-width: 280px; border: 1px solid var(--line); border-radius: 8px; padding: 0.55rem 0.75rem; }
</style>