Files
jiang13-bbs/frontend/app/admin/DashboardClient.tsx

245 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}