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} />