feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 02:26:16 +08:00
co-authored by Cursor
parent 7e9023f0a8
commit 7ab9add5dd
132 changed files with 8276 additions and 491 deletions
+125 -25
View File
@@ -4,6 +4,14 @@ export type ApiEnvelope<T> = { code: number; message: string; data?: T }
const TOKEN_KEY = 'yuxingu_admin_token'
/** Under /psy/admin use /psy/api; local vite uses /api. */
function adminAPIBase(): string {
if (typeof location !== 'undefined' && location.pathname.startsWith('/psy/admin')) {
return '/psy/api/v1/admin'
}
return '/api/v1/admin'
}
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
@@ -18,7 +26,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
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}`, {
const res = await fetch(`${adminAPIBase()}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
@@ -30,6 +38,62 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
return env.data as T
}
export type DashboardStats = {
users_total: number
membership_active: number
orders_today: number
paid_cents_today: number
ask_replies_today: number
profiles_total: number
reports_total: number
series?: Array<{
day: string
new_users: number
orders: number
paid_cents: number
ask_replies: number
}>
reports_by_type?: Array<{ type: string; count: number }>
}
export type UserListItem = {
id: string
status: string
phone?: string | null
nickname?: string | null
ask_paid_quota_left: number
profile_count: number
membership_active: boolean
membership_plan?: string | null
created_at: string
}
export type UserDetail = {
id: string
status: string
phone?: string | null
nickname?: string | null
ask_paid_quota_left: number
created_at: string
profiles: Array<{ id: string; relation: string; display_name: string; birth_date?: string }>
reports: Array<{ id: string; type: string; created_at: string }>
membership: {
active: boolean
plan?: string
status: string
expires_at?: string | null
ask_quota_left?: number
}
recent_orders: Array<{
id: string
kind: string
plan?: string
status: string
amount_cents: number
created_at: string
}>
}
export const adminApi = {
login: (username: string, password: string) =>
request<{ token: string; admin: { id: string; username: string } }>('POST', '/auth/login', {
@@ -38,14 +102,16 @@ export const adminApi = {
}),
logout: () => request<{ ok: boolean }>('POST', '/auth/logout'),
me: () => request<{ id: string; username: string }>('GET', '/me'),
stats: () => request<DashboardStats>('GET', '/stats'),
users: (q = '') =>
request<{ items: Array<{ id: string; status: string; created_at: string }> }>(
'GET',
`/users?q=${encodeURIComponent(q)}`,
),
request<{ items: UserListItem[] }>('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 }),
grantAskQuota: (id: string, delta: number) =>
request<{ ok: boolean; ask_paid_quota_left: number }>('POST', `/users/${id}/ask-quota/grant`, {
delta,
}),
orders: () =>
request<{
items: Array<{
@@ -70,26 +136,60 @@ export const adminApi = {
created_at: string
}>
}>('GET', '/audit-logs'),
analyticsOverview: (from: string, to: string) =>
request<AnalyticsOverview>('GET', `/analytics/overview?from=${from}&to=${to}`),
analyticsPages: (from: string, to: string) =>
request<{ items: AnalyticsPageRow[] }>('GET', `/analytics/pages?from=${from}&to=${to}`),
analyticsExits: (from: string, to: string) =>
request<{ items: AnalyticsExitRow[] }>('GET', `/analytics/exits?from=${from}&to=${to}`),
analyticsClicks: (from: string, to: string) =>
request<{ items: AnalyticsClickRow[] }>('GET', `/analytics/clicks?from=${from}&to=${to}`),
analyticsFunnel: (from: string, to: string) =>
request<{ steps: AnalyticsFunnelStep[] }>('GET', `/analytics/funnel?from=${from}&to=${to}`),
homeTools: () => request<{ items: HomeToolAdmin[] }>('GET', '/home/tools'),
saveHomeTools: (items: HomeToolAdmin[]) =>
request<{ ok: boolean }>('PUT', '/home/tools', { items }),
scales: () => request<{ items: ScaleAdmin[] }>('GET', '/scales'),
patchScale: (id: string, status: 'published' | 'draft') =>
request<{ ok: boolean }>('PATCH', `/scales/${id}`, { status }),
}
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
}>
export type HomeToolAdmin = {
id?: string
row_index: number
sort_order: number
path: string
icon: string
label: string
badge?: string | null
badge_tone?: string | null
enabled: boolean
}
export type ScaleAdmin = {
id: string
slug: string
title: string
description: string
status: string
}
export type AnalyticsOverview = {
dau: number
new_users: number
sessions: number
avg_session_ms: number
series?: Array<{ day: string; dau: number; sessions: number }>
}
export type AnalyticsPageRow = {
page_path: string
pv: number
uv: number
avg_dwell_ms: number
exit_count: number
}
export type AnalyticsExitRow = { exit_page: string; count: number }
export type AnalyticsClickRow = { element_id: string; count: number }
export type AnalyticsFunnelStep = { name: string; count: number }
@@ -0,0 +1,55 @@
<script setup lang="ts">
const base = import.meta.env.BASE_URL || '/'
const src = `${base}logo.png`
withDefaults(
defineProps<{
size?: 'nav' | 'login'
}>(),
{ size: 'nav' },
)
</script>
<template>
<!-- Logo 原稿为橙字透底浅色面板上易发虚深色底托保证可读 -->
<span class="brand-mark" :class="size">
<img class="brand-logo" :src="src" alt="愈心谷" decoding="async" />
</span>
</template>
<style scoped>
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
background: #1a1514;
border-radius: 12px;
box-shadow: 0 6px 16px rgba(26, 21, 20, 0.18);
}
.brand-mark.nav {
padding: 8px 12px;
max-width: 100%;
}
.brand-mark.login {
padding: 14px 18px;
margin: 0 auto 0.85rem;
border-radius: 16px;
}
.brand-logo {
display: block;
object-fit: contain;
object-position: center;
user-select: none;
-webkit-user-drag: none;
background: transparent;
}
.nav .brand-logo {
width: 132px;
max-width: 100%;
height: auto;
max-height: 32px;
}
.login .brand-logo {
width: min(200px, 68vw);
height: auto;
}
</style>
+32 -7
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterLink, RouterView, useRouter } from 'vue-router'
import BrandLogo from '@/components/BrandLogo.vue'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
@@ -19,9 +20,14 @@ async function onLogout() {
<template>
<div class="shell">
<aside class="nav">
<div class="brand">愈心谷 · 运营</div>
<RouterLink class="brand" to="/">
<BrandLogo size="nav" />
</RouterLink>
<nav>
<RouterLink to="/">用户</RouterLink>
<RouterLink to="/">概览</RouterLink>
<RouterLink to="/analytics">数据</RouterLink>
<RouterLink to="/content">内容</RouterLink>
<RouterLink to="/users">用户</RouterLink>
<RouterLink to="/orders">订单</RouterLink>
<RouterLink to="/audit">审计</RouterLink>
</nav>
@@ -39,13 +45,32 @@ async function onLogout() {
<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;
padding: 1.15rem 1rem;
border-right: 1px solid rgba(240, 230, 226, 0.9);
background: rgba(255, 253, 251, 0.88);
backdrop-filter: blur(12px);
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.brand {
display: flex;
align-items: center;
padding: 0.15rem 0.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; }
nav a {
padding: 0.5rem 0.7rem;
border-radius: 12px;
color: var(--muted);
font-weight: 550;
}
nav a.router-link-active {
background: linear-gradient(135deg, #fff0ec, #ffe4e0);
color: var(--accent);
font-weight: 700;
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.08);
}
.foot { margin-top: auto; display: flex; flex-direction: column; gap: 0.5rem; }
.main { padding: 1.5rem 1.75rem; }
@media (max-width: 800px) {
+243
View File
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
adminApi,
type AnalyticsClickRow,
type AnalyticsExitRow,
type AnalyticsFunnelStep,
type AnalyticsOverview,
type AnalyticsPageRow,
} from '@/api/client'
type RangeKey = '1' | '7' | '30'
const range = ref<RangeKey>('7')
const loading = ref(false)
const error = ref('')
const overview = ref<AnalyticsOverview | null>(null)
const pages = ref<AnalyticsPageRow[]>([])
const exits = ref<AnalyticsExitRow[]>([])
const clicks = ref<AnalyticsClickRow[]>([])
const funnel = ref<AnalyticsFunnelStep[]>([])
function dayStr(d: Date) {
return d.toISOString().slice(0, 10)
}
function rangeBounds(key: RangeKey): { from: string; to: string } {
const to = new Date()
const from = new Date()
const n = Number(key)
from.setUTCDate(from.getUTCDate() - (n - 1))
return { from: dayStr(from), to: dayStr(to) }
}
async function load() {
loading.value = true
error.value = ''
const { from, to } = rangeBounds(range.value)
try {
const [ov, pg, ex, cl, fn] = await Promise.all([
adminApi.analyticsOverview(from, to),
adminApi.analyticsPages(from, to),
adminApi.analyticsExits(from, to),
adminApi.analyticsClicks(from, to),
adminApi.analyticsFunnel(from, to),
])
overview.value = ov
pages.value = pg.items || []
exits.value = ex.items || []
clicks.value = cl.items || []
funnel.value = fn.steps || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
watch(range, load)
function fmtMs(ms: number) {
if (!ms || ms < 1000) return `${Math.round(ms || 0)}ms`
return `${(ms / 1000).toFixed(1)}s`
}
const funnelLabel: Record<string, string> = {
portrait_completed: '画像完成',
deep_access_clicked: '深度 CTA',
purchase_completed: '支付成功',
}
const trend = computed(() => {
const series = overview.value?.series || []
const labels = series.map((d) => {
const p = d.day.split('-')
return p.length === 3 ? `${Number(p[1])}/${Number(p[2])}` : d.day
})
const values = series.map((d) => d.dau)
const max = Math.max(1, ...values)
return { labels, values, max }
})
const W = 560
const H = 200
const pad = { t: 16, r: 12, b: 36, l: 36 }
const plotW = W - pad.l - pad.r
const plotH = H - pad.t - pad.b
</script>
<template>
<section>
<header class="head">
<div>
<h1>数据</h1>
<p class="muted">行为分析 · 停留 · 退出页 · 点击 · 漏斗</p>
</div>
<div class="actions">
<div class="seg">
<button
v-for="k in (['1', '7', '30'] as RangeKey[])"
:key="k"
type="button"
:class="{ on: range === k }"
@click="range = k"
>
{{ k === '1' ? '今日' : `${k}` }}
</button>
</div>
<button class="btn ghost" type="button" :disabled="loading" @click="load">刷新</button>
</div>
</header>
<p v-if="loading && !overview" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<template v-if="overview">
<div class="cards">
<div class="card"><span class="lbl">DAU</span><strong>{{ overview.dau }}</strong></div>
<div class="card"><span class="lbl">新增</span><strong>{{ overview.new_users }}</strong></div>
<div class="card"><span class="lbl">会话</span><strong>{{ overview.sessions }}</strong></div>
<div class="card">
<span class="lbl">人均会话</span>
<strong>{{ fmtMs(overview.avg_session_ms) }}</strong>
</div>
</div>
<div class="panel">
<h2>日活趋势</h2>
<svg class="chart" :viewBox="`0 0 ${W} ${H}`" role="img" aria-label="DAU trend">
<g v-for="(v, i) in trend.values" :key="i">
<rect
:x="pad.l + (i * plotW) / Math.max(trend.values.length, 1) + 4"
:y="pad.t + plotH - (v / trend.max) * plotH"
:width="Math.max(8, plotW / Math.max(trend.values.length, 1) - 8)"
:height="(v / trend.max) * plotH"
fill="#e54d42"
rx="4"
/>
<text
:x="pad.l + (i * plotW) / Math.max(trend.values.length, 1) + plotW / Math.max(trend.values.length, 1) / 2"
:y="H - 12"
text-anchor="middle"
class="tick"
>
{{ trend.labels[i] }}
</text>
</g>
</svg>
</div>
<div class="grid2">
<div class="panel">
<h2>页面明细</h2>
<table>
<thead>
<tr><th>路径</th><th>PV</th><th>UV</th><th>均停留</th><th>退出</th></tr>
</thead>
<tbody>
<tr v-for="p in pages" :key="p.page_path">
<td class="mono">{{ p.page_path }}</td>
<td>{{ p.pv }}</td>
<td>{{ p.uv }}</td>
<td>{{ fmtMs(p.avg_dwell_ms) }}</td>
<td>{{ p.exit_count }}</td>
</tr>
<tr v-if="!pages.length"><td colspan="5" class="muted">暂无</td></tr>
</tbody>
</table>
</div>
<div class="panel">
<h2>漏斗</h2>
<ul class="funnel">
<li v-for="s in funnel" :key="s.name">
<span>{{ funnelLabel[s.name] || s.name }}</span>
<strong>{{ s.count }}</strong>
</li>
</ul>
</div>
</div>
<div class="grid2">
<div class="panel">
<h2>退出页 TOP</h2>
<table>
<thead><tr><th>页面</th><th>次数</th></tr></thead>
<tbody>
<tr v-for="e in exits" :key="e.exit_page">
<td class="mono">{{ e.exit_page }}</td>
<td>{{ e.count }}</td>
</tr>
<tr v-if="!exits.length"><td colspan="2" class="muted">暂无</td></tr>
</tbody>
</table>
</div>
<div class="panel">
<h2>点击 TOP</h2>
<table>
<thead><tr><th>元素</th><th>次数</th></tr></thead>
<tbody>
<tr v-for="c in clicks" :key="c.element_id">
<td class="mono">{{ c.element_id }}</td>
<td>{{ c.count }}</td>
</tr>
<tr v-if="!clicks.length"><td colspan="2" class="muted">暂无</td></tr>
</tbody>
</table>
</div>
</div>
</template>
</section>
</template>
<style scoped>
.head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
h1 { margin: 0; font-size: 1.5rem; }
h2 { margin: 0 0 0.75rem; font-size: 1.05rem; }
.muted { color: var(--muted); }
.err { color: #c0392b; }
.actions { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
.seg button { border: 0; background: transparent; padding: 0.4rem 0.75rem; cursor: pointer; color: var(--muted); font-weight: 600; }
.seg button.on { background: linear-gradient(135deg, #fff0ec, #ffe4e0); color: var(--accent); }
.cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
.card { padding: 1rem; border-radius: 16px; background: rgba(255,253,251,0.9); border: 1px solid var(--line); display: flex; flex-direction: column; gap: 0.35rem; }
.card .lbl { color: var(--muted); font-size: 0.85rem; }
.card strong { font-size: 1.45rem; }
.panel { padding: 1rem 1.1rem; border-radius: 16px; border: 1px solid var(--line); background: rgba(255,253,251,0.9); margin-bottom: 1rem; }
.grid2 { display: grid; grid-template-columns: 1.4fr 1fr; gap: 1rem; }
.chart { width: 100%; height: auto; max-height: 220px; }
.tick { font-size: 11px; fill: #8a7a74; }
table { width: 100%; border-collapse: collapse; font-size: 0.92rem; }
th, td { text-align: left; padding: 0.45rem 0.35rem; border-bottom: 1px solid var(--line); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; }
.funnel { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.65rem; }
.funnel li { display: flex; justify-content: space-between; padding: 0.55rem 0.7rem; border-radius: 12px; background: #fff5f2; }
@media (max-width: 900px) {
.cards, .grid2 { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 560px) {
.cards, .grid2 { grid-template-columns: 1fr; }
}
</style>
+200
View File
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi, type HomeToolAdmin, type ScaleAdmin } from '@/api/client'
const tab = ref<'grid' | 'scales'>('grid')
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const tools = ref<HomeToolAdmin[]>([])
const scales = ref<ScaleAdmin[]>([])
const msg = ref('')
const icons = [
'mbti', 'star', 'portrait', 'rhythm', 'synastry', 'astro',
'companion', 'ask', 'cards', 'reports', 'growth', 'relation',
]
async function load() {
loading.value = true
error.value = ''
try {
const [t, s] = await Promise.all([adminApi.homeTools(), adminApi.scales()])
tools.value = (t.items || []).map((x) => ({ ...x, enabled: !!x.enabled }))
scales.value = s.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
async function saveTools() {
saving.value = true
msg.value = ''
error.value = ''
try {
const payload = tools.value.map((t, idx) => ({
...t,
sort_order: t.sort_order || idx + 1,
badge: t.badge || null,
badge_tone: t.badge_tone || null,
}))
await adminApi.saveHomeTools(payload)
msg.value = '宫格已保存'
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
function move(i: number, dir: -1 | 1) {
const j = i + dir
if (j < 0 || j >= tools.value.length) return
const arr = tools.value.slice()
const tmp = arr[i]
arr[i] = arr[j]
arr[j] = tmp
const counts: Record<number, number> = { 1: 0, 2: 0 }
tools.value = arr.map((t) => {
const row = t.row_index === 2 ? 2 : 1
counts[row] += 1
return { ...t, row_index: row, sort_order: counts[row] }
})
}
function addTool() {
if (tools.value.length >= 24) return
tools.value.push({
row_index: 2,
sort_order: tools.value.length + 1,
path: '/ask',
icon: 'ask',
label: '新入口',
enabled: true,
})
}
async function toggleScale(s: ScaleAdmin) {
const next = s.status === 'published' ? 'draft' : 'published'
error.value = ''
try {
await adminApi.patchScale(s.id, next)
s.status = next
msg.value = `${s.title}${next === 'published' ? '已上架' : '已下架'}`
} catch (e) {
error.value = e instanceof Error ? e.message : '更新失败'
}
}
</script>
<template>
<section>
<header class="head">
<div>
<h1>内容</h1>
<p class="muted">首页宫格 · 测评上下架</p>
</div>
<button class="btn ghost" type="button" :disabled="loading" @click="load">刷新</button>
</header>
<div class="seg">
<button type="button" :class="{ on: tab === 'grid' }" @click="tab = 'grid'">首页宫格</button>
<button type="button" :class="{ on: tab === 'scales' }" @click="tab = 'scales'">测评</button>
</div>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<p v-if="msg" class="ok">{{ msg }}</p>
<template v-if="tab === 'grid' && !loading">
<div class="toolbar">
<button class="btn ghost" type="button" @click="addTool">添加</button>
<button class="btn" type="button" :disabled="saving" @click="saveTools">保存宫格</button>
</div>
<div v-for="(t, i) in tools" :key="t.id || i" class="row">
<label class="chk"><input v-model="t.enabled" type="checkbox" /></label>
<select v-model.number="t.row_index">
<option :value="1">行1</option>
<option :value="2">行2</option>
</select>
<input v-model="t.label" class="inp" maxlength="16" placeholder="文案" />
<input v-model="t.path" class="inp path" placeholder="/path" />
<select v-model="t.icon">
<option v-for="ic in icons" :key="ic" :value="ic">{{ ic }}</option>
</select>
<input v-model="t.badge" class="inp sm" maxlength="4" placeholder="角标" />
<select v-model="t.badge_tone">
<option value="">无色</option>
<option value="hot">hot</option>
<option value="new">new</option>
</select>
<button class="btn ghost" type="button" @click="move(i, -1)"></button>
<button class="btn ghost" type="button" @click="move(i, 1)"></button>
</div>
</template>
<template v-if="tab === 'scales' && !loading">
<table>
<thead>
<tr><th>标题</th><th>slug</th><th>状态</th><th></th></tr>
</thead>
<tbody>
<tr v-for="s in scales" :key="s.id">
<td>{{ s.title }}</td>
<td class="mono">{{ s.slug }}</td>
<td>
<span :class="s.status === 'published' ? 'on' : 'off'">
{{ s.status === 'published' ? '已上架' : '草稿' }}
</span>
</td>
<td>
<button class="btn ghost" type="button" @click="toggleScale(s)">
{{ s.status === 'published' ? '下架' : '上架' }}
</button>
</td>
</tr>
</tbody>
</table>
</template>
</section>
</template>
<style scoped>
.head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; }
h1 { margin: 0; font-size: 1.5rem; }
.muted { color: var(--muted); }
.err { color: #c0392b; }
.ok { color: #2fa866; }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; margin-bottom: 1rem; }
.seg button { border: 0; background: transparent; padding: 0.45rem 0.9rem; cursor: pointer; color: var(--muted); font-weight: 600; }
.seg button.on { background: linear-gradient(135deg, #fff0ec, #ffe4e0); color: var(--accent); }
.toolbar { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
.row {
display: grid;
grid-template-columns: auto auto 7rem 1fr 6.5rem 3.5rem 4.5rem auto auto;
gap: 0.35rem;
align-items: center;
margin-bottom: 0.45rem;
padding: 0.45rem 0.5rem;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255,253,251,0.9);
}
.inp { border: 1px solid var(--line); border-radius: 8px; padding: 0.35rem 0.45rem; min-width: 0; }
.inp.path { font-family: ui-monospace, monospace; font-size: 0.85rem; }
.inp.sm { width: 3.2rem; }
.chk { font-size: 0.85rem; color: var(--muted); display: flex; gap: 0.25rem; align-items: center; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--line); }
.mono { font-family: ui-monospace, monospace; font-size: 0.85rem; }
.on { color: #2fa866; font-weight: 600; }
.off { color: var(--muted); }
@media (max-width: 960px) {
.row { grid-template-columns: 1fr 1fr; }
}
</style>
+340
View File
@@ -0,0 +1,340 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { adminApi, type DashboardStats } from '@/api/client'
const loading = ref(false)
const error = ref('')
const stats = ref<DashboardStats | null>(null)
async function load() {
loading.value = true
error.value = ''
try {
stats.value = await adminApi.stats()
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
function yuan(cents: number) {
return (cents / 100).toFixed(2)
}
function shortDay(day: string) {
const p = day.split('-')
return p.length === 3 ? `${Number(p[1])}/${Number(p[2])}` : day
}
const typeLabel: Record<string, string> = {
portrait: '愈心解码',
star: '星座',
rhythm: '节律',
relation: '关系',
synastry: '合盘',
image_card: '意象卡',
}
type BarSeries = { key: string; label: string; color: string; values: number[] }
const trend = computed(() => {
const series = stats.value?.series || []
const labels = series.map((d) => shortDay(d.day))
const bars: BarSeries[] = [
{ key: 'orders', label: '订单', color: '#e54d42', values: series.map((d) => d.orders) },
{ key: 'ask', label: '问答', color: '#4a90e2', values: series.map((d) => d.ask_replies) },
{ key: 'users', label: '新用户', color: '#c8923a', values: series.map((d) => d.new_users) },
]
const max = Math.max(1, ...bars.flatMap((b) => b.values))
return { labels, bars, max }
})
const W = 560
const H = 220
const pad = { t: 16, r: 12, b: 36, l: 36 }
const plotW = W - pad.l - pad.r
const plotH = H - pad.t - pad.b
function barX(dayIdx: number, seriesIdx: number, nDays: number, nSeries: number) {
const groupW = plotW / Math.max(nDays, 1)
const inner = groupW * 0.72
const barW = inner / nSeries
const groupStart = pad.l + dayIdx * groupW + (groupW - inner) / 2
return groupStart + seriesIdx * barW
}
function barHeight(v: number, max: number) {
return (v / max) * plotH
}
const pie = computed(() => {
const items = (stats.value?.reports_by_type || []).map((r) => ({
type: r.type,
label: typeLabel[r.type] || r.type,
count: r.count,
}))
const total = items.reduce((s, i) => s + i.count, 0) || 1
const colors = ['#e54d42', '#ff8a7c', '#c8923a', '#4a90e2', '#5cb85c', '#e8985a', '#8b5cf6']
let angle = -Math.PI / 2
const cx = 90
const cy = 90
const R = 70
const slices = items.map((it, i) => {
const sweep = (it.count / total) * Math.PI * 2
const a0 = angle
const a1 = angle + sweep
angle = a1
const x0 = cx + R * Math.cos(a0)
const y0 = cy + R * Math.sin(a0)
const x1 = cx + R * Math.cos(a1)
const y1 = cy + R * Math.sin(a1)
const large = sweep > Math.PI ? 1 : 0
const d = `M ${cx} ${cy} L ${x0} ${y0} A ${R} ${R} 0 ${large} 1 ${x1} ${y1} Z`
return { ...it, d, color: colors[i % colors.length], pct: Math.round((it.count / total) * 100) }
})
return { slices, total: items.reduce((s, i) => s + i.count, 0) }
})
const memberShare = computed(() => {
const s = stats.value
if (!s || s.users_total <= 0) return 0
return Math.min(100, Math.round((s.membership_active / s.users_total) * 100))
})
</script>
<template>
<section>
<header class="head">
<div>
<h1>概览</h1>
<p class="muted"> 7 日趋势与运营一览</p>
</div>
<button class="btn ghost" type="button" :disabled="loading" @click="load">刷新</button>
</header>
<p v-if="loading && !stats" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<template v-else-if="stats">
<div class="grid">
<div class="stat">
<span class="label">用户总数</span>
<strong>{{ stats.users_total }}</strong>
</div>
<div class="stat">
<span class="label">有效会员</span>
<strong>{{ stats.membership_active }}</strong>
<em class="hint">渗透 {{ memberShare }}%</em>
</div>
<div class="stat">
<span class="label">今日订单</span>
<strong>{{ stats.orders_today }}</strong>
</div>
<div class="stat">
<span class="label">今日成交</span>
<strong>{{ yuan(stats.paid_cents_today) }}</strong>
</div>
<div class="stat">
<span class="label">今日问答回复</span>
<strong>{{ stats.ask_replies_today }}</strong>
</div>
<div class="stat">
<span class="label">档案 / 报告</span>
<strong>{{ stats.profiles_total }} / {{ stats.reports_total }}</strong>
</div>
</div>
<div class="charts">
<div class="card chart-card">
<div class="chart-head">
<h2> 7 日趋势</h2>
<div class="legend">
<span v-for="b in trend.bars" :key="b.key"><i :style="{ background: b.color }" />{{ b.label }}</span>
</div>
</div>
<svg class="chart" :viewBox="`0 0 ${W} ${H}`" role="img" aria-label="近七日订单问答新用户柱状图">
<line
v-for="g in 4"
:key="'g'+g"
:x1="pad.l"
:x2="W - pad.r"
:y1="pad.t + (plotH * g) / 4"
:y2="pad.t + (plotH * g) / 4"
class="gridline"
/>
<template v-for="(label, di) in trend.labels" :key="label">
<rect
v-for="(b, si) in trend.bars"
:key="b.key + di"
:x="barX(di, si, trend.labels.length, trend.bars.length)"
:y="pad.t + plotH - barHeight(b.values[di] || 0, trend.max)"
:width="(plotW / Math.max(trend.labels.length, 1) * 0.72) / trend.bars.length - 1"
:height="barHeight(b.values[di] || 0, trend.max)"
:fill="b.color"
rx="3"
>
<title>{{ label }} {{ b.label }}: {{ b.values[di] || 0 }}</title>
</rect>
<text
:x="pad.l + (di + 0.5) * (plotW / Math.max(trend.labels.length, 1))"
:y="H - 12"
class="axis"
text-anchor="middle"
>{{ label }}</text>
</template>
<text :x="8" :y="pad.t + 4" class="axis">{{ trend.max }}</text>
<text :x="8" :y="pad.t + plotH" class="axis">0</text>
</svg>
</div>
<div class="card chart-card">
<h2>报告类型分布</h2>
<div v-if="!pie.slices.length" class="muted">暂无报告</div>
<div v-else class="pie-wrap">
<svg viewBox="0 0 180 180" class="pie" role="img" aria-label="报告类型饼图">
<path v-for="s in pie.slices" :key="s.type" :d="s.d" :fill="s.color">
<title>{{ s.label }} {{ s.count }}{{ s.pct }}%</title>
</path>
<circle cx="90" cy="90" r="38" fill="#fffdfb" />
<text x="90" y="86" text-anchor="middle" class="pie-total">{{ pie.total }}</text>
<text x="90" y="104" text-anchor="middle" class="pie-sub">份报告</text>
</svg>
<ul class="pie-legend">
<li v-for="s in pie.slices" :key="s.type">
<i :style="{ background: s.color }" />
<span>{{ s.label }}</span>
<strong>{{ s.count }}</strong>
</li>
</ul>
</div>
</div>
</div>
<div class="card shortcuts">
<h2>快捷入口</h2>
<div class="links">
<RouterLink class="chip" to="/users">用户管理</RouterLink>
<RouterLink class="chip" to="/orders">订单列表</RouterLink>
<RouterLink class="chip" to="/audit">操作审计</RouterLink>
</div>
</div>
</template>
</section>
</template>
<style scoped>
.head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.25rem;
}
h1 {
margin: 0;
font-size: 1.45rem;
font-family: "Noto Serif SC", "Songti SC", serif;
}
h2 { margin: 0 0 0.75rem; font-size: 1.05rem; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.85rem;
margin-bottom: 1.1rem;
}
.stat {
background: linear-gradient(145deg, #fffdfb, #fff5f2);
border: 1px solid rgba(255, 255, 255, 0.95);
border-radius: 14px;
padding: 1rem 1.05rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
box-shadow: 0 8px 24px rgba(229, 77, 66, 0.07);
}
.stat .label { font-size: 0.78rem; color: var(--muted); }
.stat strong {
font-size: 1.45rem;
font-weight: 750;
letter-spacing: -0.02em;
color: #2f2a28;
}
.hint { font-size: 0.72rem; color: var(--accent); font-style: normal; }
.charts {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: 1rem;
margin-bottom: 1rem;
}
.chart-card { overflow: hidden; }
.chart-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.5rem;
align-items: center;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
font-size: 0.78rem;
color: var(--muted);
}
.legend i {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
margin-right: 4px;
}
.chart { width: 100%; height: auto; display: block; }
.gridline { stroke: #f0e6e2; stroke-width: 1; }
.axis { fill: #b5aaa5; font-size: 11px; }
.pie-wrap {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
}
.pie { width: 180px; height: 180px; flex-shrink: 0; }
.pie-total { font-size: 18px; font-weight: 750; fill: #2f2a28; }
.pie-sub { font-size: 11px; fill: #9a8f8b; }
.pie-legend {
list-style: none;
margin: 0;
padding: 0;
flex: 1;
min-width: 140px;
}
.pie-legend li {
display: grid;
grid-template-columns: 10px 1fr auto;
gap: 0.45rem;
align-items: center;
font-size: 0.85rem;
padding: 0.28rem 0;
}
.pie-legend i {
width: 8px;
height: 8px;
border-radius: 2px;
}
.shortcuts { margin-top: 0.15rem; }
.links { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.chip {
display: inline-block;
padding: 0.45rem 0.85rem;
border-radius: 999px;
background: linear-gradient(135deg, #fff0ec, #ffe4e0);
color: var(--accent);
font-weight: 650;
font-size: 0.9rem;
}
@media (max-width: 900px) {
.charts { grid-template-columns: 1fr; }
}
</style>
+7 -4
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import BrandLogo from '@/components/BrandLogo.vue'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
@@ -29,8 +30,9 @@ async function onSubmit() {
<template>
<div class="wrap">
<form class="card login" @submit.prevent="onSubmit">
<h1>运营后台</h1>
<p class="muted">愈心谷内部工具 · Phase A</p>
<BrandLogo size="login" />
<h1>愈心谷</h1>
<p class="muted">内部运营工具</p>
<label class="field">
<span>用户名</span>
<input v-model="username" autocomplete="username" required />
@@ -49,8 +51,9 @@ async function onSubmit() {
<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; }
.login { width: min(380px, 100%); text-align: center; }
.login :deep(.field) { text-align: left; }
h1 { margin: 0 0 0.25rem; font-size: 1.45rem; font-family: "Noto Serif SC", "Songti SC", serif; }
.muted { margin: 0 0 1.2rem; }
.btn { width: 100%; }
</style>
+109 -20
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { RouterLink, useRoute } from 'vue-router'
import { adminApi, type UserDetail } from '@/api/client'
const route = useRoute()
@@ -8,7 +8,9 @@ const loading = ref(false)
const error = ref('')
const detail = ref<UserDetail | null>(null)
const plan = ref('month')
const askDelta = ref(10)
const grantMsg = ref('')
const askMsg = ref('')
async function load() {
loading.value = true
@@ -26,65 +28,142 @@ async function grant() {
grantMsg.value = ''
try {
await adminApi.grant(String(route.params.id), plan.value)
grantMsg.value = '已授予'
grantMsg.value = '会员已授予/延长'
await load()
} catch (e) {
grantMsg.value = e instanceof Error ? e.message : '授予失败'
}
}
async function grantAsk() {
askMsg.value = ''
try {
const res = await adminApi.grantAskQuota(String(route.params.id), askDelta.value)
askMsg.value = `已加额度,当前余量 ${res.ask_paid_quota_left}`
await load()
} catch (e) {
askMsg.value = e instanceof Error ? e.message : '加额度失败'
}
}
function fmtTime(iso?: string | null) {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString('zh-CN')
} catch {
return iso
}
}
const typeLabel: Record<string, string> = {
portrait: '愈心解码',
star: '星座',
rhythm: '节律',
relation: '关系',
synastry: '合盘',
image_card: '意象卡',
}
onMounted(load)
</script>
<template>
<section>
<p class="crumb"><RouterLink to="/users"> 用户列表</RouterLink></p>
<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>
<h2>账号</h2>
<div class="kv">
<div><span>昵称</span><strong>{{ detail.nickname || '—' }}</strong></div>
<div><span>手机</span><strong>{{ detail.phone || '—' }}</strong></div>
<div><span>状态</span><strong>{{ detail.status }}</strong></div>
<div><span>创建</span><strong>{{ fmtTime(detail.created_at) }}</strong></div>
<div class="wide"><span>ID</span><code>{{ detail.id }}</code></div>
</div>
</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 || '' }}
会员问答余量 {{ detail.membership.ask_quota_left ?? 0 }} ·
到期 {{ fmtTime(detail.membership.expires_at) }}
</p>
<div class="grant">
<select v-model="plan">
<option value="month"></option>
<option value="quarter"></option>
<option value="year"></option>
<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>
<h2>问答额度已购</h2>
<p>已购余量<strong>{{ detail.ask_paid_quota_left }}</strong> </p>
<div class="grant">
<select v-model.number="askDelta">
<option :value="10">+10</option>
<option :value="30">+30</option>
<option :value="100">+100</option>
</select>
<button class="btn" type="button" @click="grantAsk">增加额度</button>
<span v-if="askMsg" class="muted">{{ askMsg }}</span>
</div>
</div>
<div class="card block">
<h2>档案{{ detail.profiles?.length || 0 }}</h2>
<p v-if="!detail.profiles?.length" class="muted">无档案</p>
<table v-else>
<thead>
<tr><th>称呼</th><th>关系</th><th>生日</th><th>ID</th></tr>
</thead>
<tbody>
<tr v-for="p in detail.profiles" :key="p.id">
<td>{{ p.display_name || '未命名' }}</td>
<td>{{ p.relation }}</td>
<td>{{ p.birth_date || '—' }}</td>
<td><code>{{ p.id.slice(0, 8) }}</code></td>
</tr>
</tbody>
</table>
</div>
<div class="card block">
<h2>成长报告 {{ detail.reports?.length || 0 }}</h2>
<p v-if="!detail.reports?.length" class="muted">无报告</p>
<table v-else>
<thead>
<tr><th>类型</th><th>时间</th><th>ID</th></tr>
</thead>
<tbody>
<tr v-for="r in detail.reports" :key="r.id">
<td>{{ typeLabel[r.type] || r.type }}</td>
<td>{{ fmtTime(r.created_at) }}</td>
<td><code>{{ r.id.slice(0, 8) }}</code></td>
</tr>
</tbody>
</table>
</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>
<thead><tr><th>类型</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>
<td>{{ fmtTime(o.created_at) }}</td>
</tr>
</tbody>
</table>
@@ -94,9 +173,19 @@ onMounted(load)
</template>
<style scoped>
.crumb { margin: 0 0 0.5rem; font-size: 0.9rem; }
.crumb a { color: var(--accent); }
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; }
.kv {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
}
.kv span { display: block; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.15rem; }
.kv .wide { grid-column: 1 / -1; }
.kv code { font-size: 0.82rem; word-break: break-all; }
.grant { display: flex; flex-wrap: wrap; 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>
+76 -18
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { adminApi } from '@/api/client'
import { adminApi, type UserListItem } from '@/api/client'
const q = ref('')
const loading = ref(false)
const error = ref('')
const items = ref<Array<{ id: string; status: string; created_at: string }>>([])
const items = ref<UserListItem[]>([])
async function load() {
loading.value = true
@@ -21,15 +21,30 @@ async function load() {
}
}
function shortID(id: string) {
return id.slice(0, 8)
}
function fmtTime(iso: string) {
try {
return new Date(iso).toLocaleString('zh-CN')
} catch {
return iso
}
}
onMounted(load)
</script>
<template>
<section>
<header class="head">
<h1>用户</h1>
<div>
<h1>用户</h1>
<p class="muted">支持手机号昵称UUID 搜索</p>
</div>
<form class="search" @submit.prevent="load">
<input v-model="q" placeholder="精确 user id (UUID)" />
<input v-model="q" placeholder="手机号 / 昵称 / UUID" />
<button class="btn" type="submit">查询</button>
</form>
</header>
@@ -37,25 +52,68 @@ onMounted(load)
<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 v-else class="table-wrap">
<table>
<thead>
<tr>
<th>用户</th>
<th>会员</th>
<th>问答余量</th>
<th>档案</th>
<th>创建</th>
</tr>
</thead>
<tbody>
<tr v-for="u in items" :key="u.id">
<td>
<RouterLink class="name" :to="`/users/${u.id}`">
{{ u.nickname || u.phone || shortID(u.id) }}
</RouterLink>
<div class="sub">{{ u.phone || '未绑定手机' }} · {{ shortID(u.id) }}</div>
</td>
<td>
<span :class="u.membership_active ? 'tag on' : 'tag'">
{{ u.membership_active ? (u.membership_plan || '有效') : '未开通' }}
</span>
</td>
<td>{{ u.ask_paid_quota_left }}</td>
<td>{{ u.profile_count }}</td>
<td>{{ fmtTime(u.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</template>
<style scoped>
.head { display: flex; flex-wrap: wrap; gap: 1rem; align-items: end; justify-content: space-between; margin-bottom: 1rem; }
.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; }
.search input {
min-width: 240px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.55rem 0.75rem;
}
.table-wrap { overflow-x: auto; }
.name { font-weight: 650; color: var(--accent); }
.sub { font-size: 0.78rem; color: var(--muted); margin-top: 0.15rem; }
.tag {
display: inline-block;
font-size: 0.78rem;
padding: 0.15rem 0.45rem;
border-radius: 6px;
background: #f3f0eb;
color: var(--muted);
}
.tag.on { background: #e8f5e9; color: #2e7d32; }
</style>
+5 -2
View File
@@ -2,14 +2,17 @@ import { createRouter, createWebHistory } from 'vue-router'
import { getToken } from '@/api/client'
const router = createRouter({
history: createWebHistory(),
history: createWebHistory(import.meta.env.BASE_URL),
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: '', name: 'dashboard', component: () => import('@/pages/DashboardPage.vue') },
{ path: 'analytics', name: 'analytics', component: () => import('@/pages/AnalyticsPage.vue') },
{ path: 'content', name: 'content', component: () => import('@/pages/ContentPage.vue') },
{ 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: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
+56 -16
View File
@@ -1,36 +1,76 @@
/* 愈心谷运营后台 — 对齐 packages/ui tokens */
:root {
--bg: #f3f0eb;
--panel: #fffdf9;
--ink: #1c1917;
--muted: #78716c;
--line: #e7e5e4;
--accent: #c45c4a;
--color-primary: #e54d42;
--color-primary-hover: #d44338;
--color-primary-soft: #ffe4e4;
--color-bg-start: #ffd1c7;
--color-bg-end: #ffc8b5;
--color-bg-sheet: #fff9f7;
--color-surface: #ffffff;
--color-text-primary: #333333;
--color-text-secondary: #999999;
--color-border: #f0e6e2;
--color-accent-gold: #c8923a;
--color-accent-blue: #4a90e2;
--color-accent-green: #5cb85c;
--color-error: #e54d42;
--bg: linear-gradient(165deg, #ffe8e0 0%, #fff5f1 42%, #ffece5 100%);
--panel: #fffdfb;
--ink: #333333;
--muted: #9a8f8b;
--line: #f0e6e2;
--accent: #e54d42;
--accent-ink: #fff;
--danger: #b91c1c;
font-family: "IBM Plex Sans", "PingFang SC", "Noto Sans SC", sans-serif;
--danger: #e54d42;
--radius: 14px;
--shadow: 0 8px 28px rgba(229, 77, 66, 0.08);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
color: var(--ink);
background: var(--bg);
background: var(--color-bg-sheet);
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: linear-gradient(160deg, #f7f3ee 0%, #ebe6df 45%, #f3efea 100%); }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
background-attachment: fixed;
}
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);
border: 0; border-radius: 12px; padding: 0.55rem 1rem; cursor: pointer;
background: linear-gradient(135deg, #ff7a6e, var(--accent));
color: var(--accent-ink);
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.22);
font-weight: 650;
}
.btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); }
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
.btn.ghost {
background: transparent; color: var(--ink);
border: 1px solid var(--line); box-shadow: none; font-weight: 500;
}
.btn:disabled { opacity: 0.55; cursor: not-allowed; box-shadow: none; }
.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;
border: 1.5px solid var(--line); border-radius: 12px; padding: 0.6rem 0.75rem;
background: #fdfaf8;
}
.field input:focus, .field select:focus {
outline: none; border-color: #f0b8b0;
box-shadow: 0 0 0 3px rgba(229, 77, 66, 0.1);
}
.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;
background: var(--panel);
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: var(--radius);
padding: 1.1rem 1.2rem;
box-shadow: var(--shadow);
}
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }