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:
Blizzard
2026-07-30 19:01:48 +08:00
parent c48e241fe1
commit 67b97e4b38
23 changed files with 1444 additions and 156 deletions
+88 -21
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { api } from '@/lib/api'
import { usePaged } from '@/lib/usePaged'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
@@ -6,9 +7,31 @@ import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Pager } from '@/components/Pager'
export default function Comments() {
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.comments)
const statusMap: Record<string, { label: string; variant: any }> = {
pending: { label: '待审核', variant: 'warning' },
published: { label: '正常', variant: 'success' },
rejected: { label: '已打回', variant: 'destructive' },
deleted: { label: '已删除', variant: 'destructive' },
}
const FILTERS = [
{ label: '待审核', status: 'pending' },
{ label: '正常', status: 'published' },
{ label: '全部', status: '' },
]
function isUrl(s: string) {
return typeof s === 'string' && s.indexOf('http') === 0
}
export default function Comments() {
const [status, setStatus] = useState('pending')
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.comments, { status })
async function setStat(id: number, s: string) {
await api.setCommentStatus(id, s)
reload()
}
async function del(id: number) {
await api.deleteComment(id)
reload()
@@ -16,39 +39,83 @@ export default function Comments() {
return (
<div>
<h1 className="text-2xl font-bold mb-6"></h1>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<div className="flex gap-1 rounded-lg border bg-card p-1">
{FILTERS.map((f) => (
<button
key={f.status}
onClick={() => { setStatus(f.status); setPage(1) }}
className={`rounded-md px-3 py-1 text-sm transition ${
status === f.status ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{f.label}
</button>
))}
</div>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.list?.map((c) => (
<TableRow key={c.id}>
<TableCell>{c.id}</TableCell>
<TableCell>{c.post_id}</TableCell>
<TableCell className="whitespace-nowrap">{c.author_name}</TableCell>
<TableCell className="max-w-sm truncate">{c.content}</TableCell>
<TableCell>
{c.status === 'deleted' ? <Badge variant="destructive"></Badge> : <Badge variant="success"></Badge>}
</TableCell>
<TableCell className="text-right">
{c.status !== 'deleted' && (
<Button variant="destructive" size="sm" onClick={() => del(c.id)}>
</Button>
)}
{data?.list?.map((c) => {
const imgs: string[] = Array.isArray(c.images) ? c.images.filter(isUrl) : []
return (
<TableRow key={c.id}>
<TableCell className="whitespace-nowrap align-top">{c.author_name}</TableCell>
<TableCell className="max-w-xs align-top">
<div className="line-clamp-3 whitespace-pre-wrap text-sm">{c.content || '—'}</div>
</TableCell>
<TableCell className="align-top">
{imgs.length ? (
<div className="flex flex-wrap gap-1" style={{ maxWidth: 180 }}>
{imgs.map((u, i) => (
<a key={i} href={u} target="_blank" rel="noreferrer">
<img src={u} className="h-14 w-14 rounded-md border object-cover" loading="lazy" />
</a>
))}
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="align-top text-xs text-muted-foreground">{c.post_id}</TableCell>
<TableCell className="align-top">
<Badge variant={statusMap[c.status]?.variant}>{statusMap[c.status]?.label ?? c.status}</Badge>
</TableCell>
<TableCell className="space-x-2 whitespace-nowrap text-right align-top">
{c.status !== 'published' && c.status !== 'deleted' && (
<Button variant="default" size="sm" onClick={() => setStat(c.id, 'published')}>
</Button>
)}
{c.status !== 'deleted' && (
<Button variant="destructive" size="sm" onClick={() => del(c.id)}>
</Button>
)}
</TableCell>
</TableRow>
)
})}
{!data?.list?.length && (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
</TableCell>
</TableRow>
))}
)}
</TableBody>
</Table>
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
+167 -19
View File
@@ -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>
)
}
+97 -40
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { api } from '@/lib/api'
import { usePaged } from '@/lib/usePaged'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
@@ -7,12 +8,25 @@ import { Card, CardContent } from '@/components/ui/card'
import { Pager } from '@/components/Pager'
const statusMap: Record<string, { label: string; variant: any }> = {
pending: { label: '待审核', variant: 'warning' },
published: { label: '已发布', variant: 'success' },
rejected: { label: '已打回', variant: 'destructive' },
hidden: { label: '已隐藏', variant: 'muted' },
deleted: { label: '已删除', variant: 'destructive' },
}
// 今天的只看时分,往前的看月日——审核时关心的是「多久以前发的」
const FILTERS = [
{ label: '待审核', status: 'pending' },
{ label: '已发布', status: 'published' },
{ label: '已打回', status: 'rejected' },
{ label: '全部', status: '' },
]
// 后台展示的图片可能是真实 URL,也可能是机器人帖的 emoji 占位——只渲染 URL
function isUrl(s: string) {
return typeof s === 'string' && s.indexOf('http') === 0
}
function fmtTime(iso?: string) {
if (!iso) return '—'
const d = new Date(iso)
@@ -21,72 +35,115 @@ function fmtTime(iso?: string) {
const hm = `${p(d.getHours())}:${p(d.getMinutes())}`
const now = new Date()
if (d.toDateString() === now.toDateString()) return `今天 ${hm}`
const y = new Date(now.getTime() - 86400000)
if (d.toDateString() === y.toDateString()) return `昨天 ${hm}`
const sameYear = d.getFullYear() === now.getFullYear()
return `${sameYear ? '' : d.getFullYear() + '-'}${p(d.getMonth() + 1)}-${p(d.getDate())} ${hm}`
}
export default function Posts() {
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.posts)
const [status, setStatus] = useState('pending')
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.posts, { status })
async function setStatus(id: number, status: string) {
await api.setPostStatus(id, status)
async function setPostStatus(id: number, s: string) {
await api.setPostStatus(id, s)
reload()
}
return (
<div>
<h1 className="text-2xl font-bold mb-6"></h1>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<div className="flex gap-1 rounded-lg border bg-card p-1">
{FILTERS.map((f) => (
<button
key={f.status}
onClick={() => { setStatus(f.status); setPage(1) }}
className={`rounded-md px-3 py-1 text-sm transition ${
status === f.status ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{f.label}
</button>
))}
</div>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>/</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.list?.map((p) => (
<TableRow key={p.id}>
<TableCell>{p.id}</TableCell>
<TableCell className="whitespace-nowrap">
<span className="mr-1">{p.author_emoji}</span>
{p.author_name}
</TableCell>
<TableCell className="max-w-sm truncate">{p.content}</TableCell>
<TableCell className="whitespace-nowrap">
{p.like_count} / {p.comment_count}
</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">
{fmtTime(p.created_at)}
</TableCell>
<TableCell>
<Badge variant={statusMap[p.status]?.variant}>{statusMap[p.status]?.label ?? p.status}</Badge>
</TableCell>
<TableCell className="text-right space-x-2 whitespace-nowrap">
{p.status !== 'published' && (
<Button variant="outline" size="sm" onClick={() => setStatus(p.id, 'published')}>
{data?.list?.map((p) => {
const imgs: string[] = Array.isArray(p.images) ? p.images.filter(isUrl) : []
return (
<TableRow key={p.id}>
<TableCell className="whitespace-nowrap align-top">
<span className="mr-1">{p.author_emoji}</span>
{p.author_name}
{p.is_ai && <Badge variant="muted" className="ml-1">AI</Badge>}
</TableCell>
<TableCell className="max-w-xs align-top">
<div className="line-clamp-3 whitespace-pre-wrap text-sm">{p.content || '—'}</div>
</TableCell>
<TableCell className="align-top">
{imgs.length ? (
<div className="flex flex-wrap gap-1" style={{ maxWidth: 220 }}>
{imgs.map((u, i) => (
<a key={i} href={u} target="_blank" rel="noreferrer">
<img
src={u}
className="h-16 w-16 rounded-md object-cover border"
loading="lazy"
/>
</a>
))}
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="whitespace-nowrap align-top">
{p.like_count} / {p.comment_count}
</TableCell>
<TableCell className="whitespace-nowrap align-top text-muted-foreground">
{fmtTime(p.created_at)}
</TableCell>
<TableCell className="align-top">
<Badge variant={statusMap[p.status]?.variant}>{statusMap[p.status]?.label ?? p.status}</Badge>
</TableCell>
<TableCell className="space-x-2 whitespace-nowrap text-right align-top">
{p.status !== 'published' && (
<Button variant="default" size="sm" onClick={() => setPostStatus(p.id, 'published')}>
</Button>
)}
{(p.status === 'pending' || p.status === 'published') && (
<Button variant="outline" size="sm" onClick={() => setPostStatus(p.id, 'rejected')}>
</Button>
)}
<Button variant="destructive" size="sm" onClick={() => setPostStatus(p.id, 'deleted')}>
</Button>
)}
{p.status === 'published' && (
<Button variant="outline" size="sm" onClick={() => setStatus(p.id, 'hidden')}>
</Button>
)}
<Button variant="destructive" size="sm" onClick={() => setStatus(p.id, 'deleted')}>
</Button>
</TableCell>
</TableRow>
)
})}
{!data?.list?.length && (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
</TableCell>
</TableRow>
))}
)}
</TableBody>
</Table>
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />