- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配
248 lines
8.3 KiB
TypeScript
248 lines
8.3 KiB
TypeScript
"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 { postPath, userPath } from "@/lib/urlStyle";
|
||
import Avatar from "@/components/Avatar";
|
||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||
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 urlStyle = useUrlStyle();
|
||
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={userPath(urlStyle, 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={postPath(urlStyle, 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>
|
||
);
|
||
}
|