Files
sundynix-pets/pets-be/web/admin/src/pages/Dashboard.tsx
T
Blizzard 67b97e4b38 feat(be+admin): 后台看板扩展 + 社区内容安全审核
看板(Recharts):
- 概览页加 DAU/WAU/MAU、今日/本月新增 KPI,新增趋势/活跃趋势/月活/留存曲线
- GET /admin/analytics 内存计算,活跃口径=当天有记录/发帖/评论,排除 bot

内容安全(微信官方 UGC):
- 文本 msg_sec_check 同步判、图片 media_check_async 异步查
- 帖子/评论加 pending/rejected 审核态,feed 只放 published
- 图片结果回调 /api/wx/sec-callback,JSON/XML + 明文模式,签名校验
- 检测不了/未发布时一律转待审核,走后台手动审核
- 后台帖子/评论审核页:修好看不到图(补 attachPostImages)、
  加状态筛选 + 通过/打回;新增评论审核状态接口

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:01:48 +08:00

188 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import { Users, PawPrint, MessageSquare, Activity, TrendingUp, CalendarDays, UserPlus, Repeat } from 'lucide-react'
import {
ResponsiveContainer, AreaChart, Area, BarChart, Bar, LineChart, Line,
XAxis, YAxis, CartesianGrid, Tooltip, Cell,
} from 'recharts'
import { api } from '@/lib/api'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
const ORANGE = '#F5A93E'
const GREEN = '#3FB984'
const BLUE = '#5B9BD5'
type Pt = { date: string; count: number }
type Ret = { day: number; rate: number; base: number }
type Analytics = {
summary: {
total_users: number; new_today: number; new_month: number
dau: number; wau: number; mau: number
total_pets: number; total_posts: number; total_records: number
}
new_trend: Pt[]; active_trend: Pt[]; mau_trend: Pt[]; retention: Ret[]
}
const RANGES = [
{ label: '近 7 天', days: 7 },
{ label: '近 30 天', days: 30 },
{ label: '近 90 天', days: 90 },
]
export default function Dashboard() {
const [days, setDays] = useState(30)
const [a, setA] = useState<Analytics | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
api.analytics(days).then((d) => { setA(d); setLoading(false) }).catch(() => setLoading(false))
}, [days])
const s = a?.summary
const kpis = [
{ label: '真实用户', value: s?.total_users, icon: Users, tint: ORANGE },
{ label: '日活 DAU', value: s?.dau, icon: Activity, tint: GREEN },
{ label: '周活 WAU', value: s?.wau, icon: CalendarDays, tint: BLUE },
{ label: '月活 MAU', value: s?.mau, icon: TrendingUp, tint: ORANGE },
{ label: '今日新增', value: s?.new_today, icon: UserPlus, tint: GREEN },
{ label: '本月新增', value: s?.new_month, icon: UserPlus, tint: BLUE },
]
const mini = [
{ label: '宠物', value: s?.total_pets, icon: PawPrint },
{ label: '帖子', value: s?.total_posts, icon: MessageSquare },
{ label: '健康记录', value: s?.total_records, icon: Activity },
]
const retData = (a?.retention || []).map((r) => ({ ...r, pct: Math.round(r.rate * 1000) / 10 }))
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<div className="flex gap-1 rounded-lg border bg-card p-1">
{RANGES.map((r) => (
<button
key={r.days}
onClick={() => setDays(r.days)}
className={`rounded-md px-3 py-1 text-sm transition ${
days === r.days ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{r.label}
</button>
))}
</div>
</div>
{/* KPI 卡 */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-6">
{kpis.map((c) => (
<Card key={c.label}>
<CardHeader className="flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs text-muted-foreground">{c.label}</CardTitle>
<c.icon className="h-4 w-4" style={{ color: c.tint }} />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{c.value ?? '—'}</div>
</CardContent>
</Card>
))}
</div>
{/* 图表区 */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<ChartCard title="新增趋势" hint={`每日新增真实用户 · ${days}`}>
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={a?.new_trend || []} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
<defs>
<linearGradient id="gNew" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={ORANGE} stopOpacity={0.35} />
<stop offset="100%" stopColor={ORANGE} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} interval="preserveStartEnd" minTickGap={24} />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} width={36} />
<Tooltip {...tipProps} />
<Area type="monotone" dataKey="count" name="新增" stroke={ORANGE} strokeWidth={2} fill="url(#gNew)" />
</AreaChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="活跃趋势" hint={`每日活跃用户 DAU · ${days}`}>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={a?.active_trend || []} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} interval="preserveStartEnd" minTickGap={24} />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} width={36} />
<Tooltip {...tipProps} />
<Line type="monotone" dataKey="count" name="活跃" stroke={GREEN} strokeWidth={2.5} dot={false} />
</LineChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="月活 MAU" hint="近 6 个月去重活跃用户">
<ResponsiveContainer width="100%" height={260}>
<BarChart data={a?.mau_trend || []} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} width={36} />
<Tooltip {...tipProps} />
<Bar dataKey="count" name="月活" fill={BLUE} radius={[6, 6, 0, 0]} maxBarSize={44} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="留存曲线" hint="注册后第 N 天仍活跃的比例(滚动口径)">
<ResponsiveContainer width="100%" height={260}>
<LineChart data={retData} margin={{ top: 8, right: 12, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
<XAxis dataKey="day" tick={{ fontSize: 11 }} tickFormatter={(d) => `D${d}`} />
<YAxis tick={{ fontSize: 11 }} width={40} unit="%" domain={[0, 100]} />
<Tooltip
contentStyle={tipProps.contentStyle}
formatter={(v: any, _n: any, p: any) => [`${v}%(分母 ${p.payload.base}`, '留存率']}
labelFormatter={(d) => `注册后第 ${d}`}
/>
<Line type="monotone" dataKey="pct" name="留存率" stroke={ORANGE} strokeWidth={2.5}>
{retData.map((_, i) => <Cell key={i} />)}
</Line>
</LineChart>
</ResponsiveContainer>
</ChartCard>
</div>
{/* 底部小计数 */}
<div className="grid grid-cols-3 gap-4">
{mini.map((c) => (
<Card key={c.label}>
<CardHeader className="flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs text-muted-foreground">{c.label}</CardTitle>
<c.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent><div className="text-2xl font-bold">{c.value ?? '—'}</div></CardContent>
</Card>
))}
</div>
{loading && <div className="text-center text-sm text-muted-foreground"></div>}
</div>
)
}
const tipProps = {
contentStyle: { borderRadius: 10, border: '1px solid #eee', fontSize: 12 },
cursor: { fill: 'rgba(245,169,62,.08)' },
}
function ChartCard({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{title}</CardTitle>
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
)
}