feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 04:00:14 +08:00
parent cda5fad2e5
commit 9a55c2f6f2
41 changed files with 5655 additions and 1810 deletions

View File

@@ -0,0 +1,244 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { ChevronRight, Eye, UserPlus, Users } from "lucide-react";
import {
apiAdminAnalytics,
type AnalyticsOverview,
type NewUser,
} from "@/lib/api";
import { postTypeLabel } from "@/lib/postMeta";
import { formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
import { AdminSegmented } from "@/components/admin";
import {
AdminChartCard,
DualLineChart,
NamedBarChart,
NamedPieChart,
SingleAreaChart,
VisitTrendChart,
} from "@/components/admin/charts";
type RangeKey = "7d" | "30d";
const fmt = (n: number) => n.toLocaleString("zh-CN");
function PanelHead({ title, extra }: { title: string; extra?: string }) {
return (
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between gap-2"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-medium text-[15px]" style={{ color: "var(--ink)" }}>
{title}
</span>
{extra && (
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{extra}
</span>
)}
</div>
);
}
function mergePostCommentSeries(data: AnalyticsOverview) {
const map = new Map<string, { date: string; posts: number; comments: number }>();
for (const row of data.posts_series) {
map.set(row.date, { date: row.date, posts: row.count, comments: 0 });
}
for (const row of data.comments_series) {
const prev = map.get(row.date);
if (prev) prev.comments = row.count;
else map.set(row.date, { date: row.date, posts: 0, comments: row.count });
}
return [...map.values()].sort((a, b) => a.date.localeCompare(b.date));
}
export default function DashboardClient({
initialAnalytics,
newUsers,
totalUsers,
canUsers,
}: {
initialAnalytics: AnalyticsOverview;
newUsers: NewUser[];
totalUsers?: number;
canUsers: boolean;
}) {
const [range, setRange] = useState<RangeKey>("7d");
const [data, setData] = useState(initialAnalytics);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const switchRange = (next: RangeKey) => {
if (next === range || pending) return;
setRange(next);
setError(null);
startTransition(async () => {
try {
const res = await apiAdminAnalytics(next);
setData(res);
} catch {
setError("趋势数据加载失败,请稍后重试");
}
});
};
const activitySeries = mergePostCommentSeries(data);
const registerSeries = data.registers_series.map((r) => ({
date: r.date,
count: r.count,
}));
const boardBars = data.boards.map((b) => ({
label: b.label || b.key,
count: b.count,
}));
const typeSlices = data.post_types.map((t) => ({
label: postTypeLabel(t.key),
count: t.count,
}));
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="meta text-[12.5px]" style={{ color: "var(--ink-3)" }}>
{pending ? "正在切换区间…" : `近 ${data.range_days} 日趋势`}
</p>
<AdminSegmented<RangeKey>
ariaLabel="分析时间范围"
size="sm"
role="group"
value={range}
onChange={switchRange}
options={[
{ key: "7d", label: "近 7 日" },
{ key: "30d", label: "近 30 日" },
]}
/>
</div>
{error && (
<p className="text-[13px] px-1" style={{ color: "var(--danger)" }} role="alert">
{error}
</p>
)}
<AdminChartCard title="访问趋势" hint="页面浏览量(PV)与独立访客(UV)">
<VisitTrendChart data={data.visits} />
</AdminChartCard>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<AdminChartCard title="发帖与评论" hint="按日新增">
<DualLineChart
data={activitySeries}
aKey="posts"
bKey="comments"
aName="发帖"
bName="评论"
/>
</AdminChartCard>
<AdminChartCard title="新注册" hint="按日新增成员">
<SingleAreaChart data={registerSeries} dataKey="count" name="注册" color="var(--ok)" />
</AdminChartCard>
<AdminChartCard title="板块发帖分布" hint="区间内发帖量">
<NamedBarChart data={boardBars} layout="horizontal" />
</AdminChartCard>
<AdminChartCard title="帖子类型" hint="区间内发帖构成">
<NamedPieChart data={typeSlices} />
</AdminChartCard>
</div>
<div className="grid lg:grid-cols-2 gap-4 items-start">
<div className="panel overflow-hidden">
<PanelHead
title="最新注册成员"
extra={totalUsers != null ? `共 ${fmt(totalUsers)} 人` : undefined}
/>
{newUsers.length === 0 ? (
<p className="meta text-center py-14 text-[13px]">暂无成员数据</p>
) : (
newUsers.slice(0, 8).map((u) => (
<Link
key={u.id}
href={`/u/${u.id}`}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }}
>
<Avatar
name={u.nickname || u.username}
src={u.avatar || undefined}
size={34}
/>
<span className="min-w-0 flex-1 leading-tight">
<span className="block text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</span>
<span className="block text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
</span>
</span>
<span
className="shrink-0 inline-flex items-center gap-1 text-[11.5px]"
style={{ color: "var(--ink-3)" }}
>
<UserPlus size={12} aria-hidden />
{formatRelative(u.created_at)}加入
</span>
</Link>
))
)}
{canUsers && (
<Link
href="/admin/users"
className="flex items-center justify-center gap-1.5 px-4 py-3 text-[13px] transition-colors hover:bg-[var(--panel-2)]"
style={{
borderTop: "1px solid var(--line)",
color: "var(--accent)",
}}
>
<Users size={14} aria-hidden />
前往用户管理
<ChevronRight size={14} aria-hidden />
</Link>
)}
</div>
<div className="panel overflow-hidden">
<PanelHead title="热门浏览" extra={`近 ${data.range_days} 日`} />
{data.top_viewed.length === 0 ? (
<p className="meta text-center py-14 text-[13px]">暂无浏览数据</p>
) : (
data.top_viewed.map((p, i) => (
<Link
key={p.id}
href={`/post/${p.id}`}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }}
>
<span
className="w-7 h-7 rounded-md flex items-center justify-center shrink-0 text-[12px] font-bold tabular-nums"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
aria-hidden
>
{i + 1}
</span>
<span className="min-w-0 flex-1 text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{p.title}
</span>
<span
className="shrink-0 inline-flex items-center gap-1 text-[12px] tabular-nums"
style={{ color: "var(--ink-3)" }}
>
<Eye size={13} aria-hidden />
{fmt(p.view_count)}
</span>
</Link>
))
)}
</div>
</div>
</div>
);
}