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
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
+95
View File
@@ -0,0 +1,95 @@
/** Thin admin API client → /api/v1/admin (proxied to Go). */
export type ApiEnvelope<T> = { code: number; message: string; data?: T }
const TOKEN_KEY = 'yuxingu_admin_token'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string | null) {
if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY)
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { Accept: 'application/json' }
if (body !== undefined) headers['Content-Type'] = 'application/json'
const token = getToken()
if (token) headers.Authorization = `Bearer ${token}`
const res = await fetch(`/api/v1/admin${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
const env = (await res.json()) as ApiEnvelope<T>
if (!res.ok || env.code !== 0) {
throw new Error(env.message || `HTTP ${res.status}`)
}
return env.data as T
}
export const adminApi = {
login: (username: string, password: string) =>
request<{ token: string; admin: { id: string; username: string } }>('POST', '/auth/login', {
username,
password,
}),
logout: () => request<{ ok: boolean }>('POST', '/auth/logout'),
me: () => request<{ id: string; username: string }>('GET', '/me'),
users: (q = '') =>
request<{ items: Array<{ id: string; status: string; created_at: string }> }>(
'GET',
`/users?q=${encodeURIComponent(q)}`,
),
user: (id: string) => request<UserDetail>('GET', `/users/${id}`),
grant: (id: string, plan: string) =>
request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }),
orders: () =>
request<{
items: Array<{
id: string
user_id: string
kind: string
plan?: string
amount_cents: number
status: string
created_at: string
}>
}>('GET', '/orders'),
audit: () =>
request<{
items: Array<{
id: string
admin_id: string
action: string
target_type: string
target_id: string
meta: unknown
created_at: string
}>
}>('GET', '/audit-logs'),
}
export type UserDetail = {
id: string
status: string
created_at: string
profiles: Array<{ id: string; relation: string; display_name: string }>
membership: {
active: boolean
plan?: string
status: string
expires_at?: string
ask_quota_left?: number
}
recent_orders: Array<{
id: string
kind: string
plan?: string
status: string
amount_cents: number
created_at: string
}>
}
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterLink, RouterView, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
onMounted(() => {
void auth.hydrate()
})
async function onLogout() {
await auth.logout()
await router.push({ name: 'login' })
}
</script>
<template>
<div class="shell">
<aside class="nav">
<div class="brand">愈心谷 · 运营</div>
<nav>
<RouterLink to="/">用户</RouterLink>
<RouterLink to="/orders">订单</RouterLink>
<RouterLink to="/audit">审计</RouterLink>
</nav>
<div class="foot">
<span class="muted">{{ auth.username || '管理员' }}</span>
<button class="btn ghost" type="button" @click="onLogout">退出</button>
</div>
</aside>
<main class="main">
<RouterView />
</main>
</div>
</template>
<style scoped>
.shell { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
.nav {
padding: 1.25rem 1rem; border-right: 1px solid var(--line);
background: rgba(255, 253, 249, 0.85); display: flex; flex-direction: column; gap: 1.25rem;
}
.brand { font-weight: 700; letter-spacing: 0.02em; }
nav { display: flex; flex-direction: column; gap: 0.35rem; }
nav a { padding: 0.45rem 0.6rem; border-radius: 8px; color: var(--muted); }
nav a.router-link-active { background: #f5ebe6; color: var(--accent); font-weight: 600; }
.foot { margin-top: auto; display: flex; flex-direction: column; gap: 0.5rem; }
.main { padding: 1.5rem 1.75rem; }
@media (max-width: 800px) {
.shell { grid-template-columns: 1fr; }
.nav { border-right: 0; border-bottom: 1px solid var(--line); }
}
</style>
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './styles.css'
createApp(App).use(createPinia()).use(router).mount('#app')
+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>
+27
View File
@@ -0,0 +1,27 @@
import { createRouter, createWebHistory } from 'vue-router'
import { getToken } from '@/api/client'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: () => import('@/pages/LoginPage.vue'), meta: { public: true } },
{
path: '/',
component: () => import('@/layouts/AdminShell.vue'),
children: [
{ path: '', 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: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
],
},
],
})
router.beforeEach((to) => {
if (to.meta.public) return true
if (!getToken()) return { name: 'login', query: { redirect: to.fullPath } }
return true
})
export default router
+41
View File
@@ -0,0 +1,41 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { adminApi, getToken, setToken } from '@/api/client'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(getToken())
const username = ref<string>('')
async function login(user: string, password: string) {
const res = await adminApi.login(user, password)
setToken(res.token)
token.value = res.token
username.value = res.admin.username
}
async function hydrate() {
if (!token.value) return false
try {
const me = await adminApi.me()
username.value = me.username
return true
} catch {
setToken(null)
token.value = null
return false
}
}
async function logout() {
try {
await adminApi.logout()
} catch {
/* ignore */
}
setToken(null)
token.value = null
username.value = ''
}
return { token, username, login, logout, hydrate }
})
+36
View File
@@ -0,0 +1,36 @@
:root {
--bg: #f3f0eb;
--panel: #fffdf9;
--ink: #1c1917;
--muted: #78716c;
--line: #e7e5e4;
--accent: #c45c4a;
--accent-ink: #fff;
--danger: #b91c1c;
font-family: "IBM Plex Sans", "PingFang SC", "Noto Sans SC", sans-serif;
color: var(--ink);
background: var(--bg);
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: linear-gradient(160deg, #f7f3ee 0%, #ebe6df 45%, #f3efea 100%); }
a { color: inherit; text-decoration: none; }
button, input, select { font: inherit; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.65rem 0.75rem; border-bottom: 1px solid var(--line); font-size: 0.92rem; }
th { color: var(--muted); font-weight: 600; }
.btn {
border: 0; border-radius: 8px; padding: 0.55rem 1rem; cursor: pointer;
background: var(--accent); color: var(--accent-ink);
}
.btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); }
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
.field { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.9rem; }
.field input, .field select {
border: 1px solid var(--line); border-radius: 8px; padding: 0.6rem 0.75rem; background: #fff;
}
.err { color: var(--danger); font-size: 0.9rem; }
.muted { color: var(--muted); }
.card {
background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.1rem 1.2rem;
}