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
+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>