feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+125
-25
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user