feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
244
frontend/app/admin/DashboardClient.tsx
Normal file
244
frontend/app/admin/DashboardClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user