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>
This commit is contained in:
@@ -1,39 +1,187 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Users, PawPrint, MessageSquare, Activity } from 'lucide-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'
|
||||
|
||||
type Stats = { users: number; pets: number; posts: number; records: number }
|
||||
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 [stats, setStats] = useState<Stats | null>(null)
|
||||
useEffect(() => {
|
||||
api.stats().then(setStats).catch(() => {})
|
||||
}, [])
|
||||
const [days, setDays] = useState(30)
|
||||
const [a, setA] = useState<Analytics | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const cards = [
|
||||
{ label: '用户', value: stats?.users, icon: Users },
|
||||
{ label: '宠物', value: stats?.pets, icon: PawPrint },
|
||||
{ label: '帖子', value: stats?.posts, icon: MessageSquare },
|
||||
{ label: '健康记录', value: stats?.records, icon: Activity },
|
||||
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>
|
||||
<h1 className="text-2xl font-bold mb-6">概览</h1>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{cards.map((c) => (
|
||||
<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 pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">{c.label}</CardTitle>
|
||||
<c.icon className="h-4 w-4 text-primary" />
|
||||
<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-3xl font-bold">{c.value ?? '—'}</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user