feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
74
frontend/app/admin/analytics/AnalyticsShell.tsx
Normal file
74
frontend/app/admin/analytics/AnalyticsShell.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useRef, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { AdminPageHeader } from "@/components/admin";
|
||||
|
||||
export const ANALYTICS_TABS: { href: string; label: string }[] = [
|
||||
{ href: "/admin/analytics", label: "概览" },
|
||||
{ href: "/admin/analytics/realtime", label: "实时" },
|
||||
{ href: "/admin/analytics/sources", label: "来源分析" },
|
||||
{ href: "/admin/analytics/pages", label: "受访页面" },
|
||||
{ href: "/admin/analytics/visitors", label: "访客画像" },
|
||||
{ href: "/admin/analytics/security", label: "爬虫与安全" },
|
||||
{ href: "/admin/analytics/details", label: "访问明细" },
|
||||
{ href: "/admin/analytics/settings", label: "设置" },
|
||||
];
|
||||
|
||||
export default function AnalyticsShell({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const navRefs = useRef<(HTMLAnchorElement | null)[]>([]);
|
||||
|
||||
// ←→/Home/End 键盘导航,范式同 SettingsShell(纵向改横向)
|
||||
const onNavKeyDown = (e: KeyboardEvent<HTMLAnchorElement>, i: number) => {
|
||||
if (
|
||||
e.key !== "ArrowRight" &&
|
||||
e.key !== "ArrowLeft" &&
|
||||
e.key !== "Home" &&
|
||||
e.key !== "End"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
let next = i;
|
||||
if (e.key === "ArrowRight") next = (i + 1) % ANALYTICS_TABS.length;
|
||||
else if (e.key === "ArrowLeft") next = (i + ANALYTICS_TABS.length - 1) % ANALYTICS_TABS.length;
|
||||
else if (e.key === "Home") next = 0;
|
||||
else next = ANALYTICS_TABS.length - 1;
|
||||
navRefs.current[next]?.focus();
|
||||
router.push(ANALYTICS_TABS[next].href);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-analytics-page">
|
||||
<AdminPageHeader
|
||||
icon={BarChart3}
|
||||
title="访问统计"
|
||||
description="站点流量、来源、访客与安全事件的采集与分析"
|
||||
/>
|
||||
<nav className="admin-analytics-tabs" aria-label="访问统计分类">
|
||||
{ANALYTICS_TABS.map((item, i) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
ref={(el) => {
|
||||
navRefs.current[i] = el;
|
||||
}}
|
||||
href={item.href}
|
||||
className="admin-analytics-tab"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onKeyDown={(e) => onNavKeyDown(e, i)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
frontend/app/admin/analytics/OverviewClient.tsx
Normal file
137
frontend/app/admin/analytics/OverviewClient.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Eye, Users, LogIn, CalendarDays } from "lucide-react";
|
||||
import { AdminStatCard } from "@/components/admin";
|
||||
import { AdminChartCard, NamedBarChart, NamedPieChart, VisitTrendChart } from "@/components/admin/charts";
|
||||
import { apiStatsOverview, type VisitOverview, type VisitPageRow } from "@/lib/api";
|
||||
import { formatInt, RangeSegmented, StatsState, TableEmpty, deviceLabel, refKindLabel } from "./ui";
|
||||
|
||||
export default function OverviewClient({ initial }: { initial: VisitOverview | null }) {
|
||||
const [range, setRange] = useState<"7d" | "30d">("7d");
|
||||
const [data, setData] = useState<VisitOverview | null>(initial);
|
||||
const [loading, setLoading] = useState(!initial);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
apiStatsOverview(range)
|
||||
.then((d) => {
|
||||
if (alive) {
|
||||
setData(d);
|
||||
setError("");
|
||||
}
|
||||
})
|
||||
.catch(() => alive && setError("加载失败,请稍后重试"))
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [range]);
|
||||
|
||||
const delta = (today: number, yesterday: number) => {
|
||||
if (yesterday <= 0) return today > 0 ? "+100%" : "—";
|
||||
const pct = Math.round(((today - yesterday) / yesterday) * 100);
|
||||
return `${pct >= 0 ? "+" : ""}${pct}%`;
|
||||
};
|
||||
|
||||
if (!data) return <StatsState loading={loading} error={error || undefined} />;
|
||||
|
||||
const devices = data.devices.map((d) => ({ label: deviceLabel(d.key), count: d.count }));
|
||||
const sources = data.sources.map((d) => ({ label: refKindLabel(d.key), count: d.count }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p className="meta text-[12.5px]">
|
||||
今日 PV {formatInt(data.today_pv)} · UV {formatInt(data.today_uv)}
|
||||
</p>
|
||||
<RangeSegmented value={range} onChange={setRange} />
|
||||
</div>
|
||||
|
||||
{error ? <StatsState error={error} /> : null}
|
||||
|
||||
<div className="grid gap-3 grid-cols-2 xl:grid-cols-4">
|
||||
<AdminStatCard
|
||||
label="今日 PV"
|
||||
value={formatInt(data.today_pv)}
|
||||
sub={`${delta(data.today_pv, data.yesterday_pv)} vs 昨日`}
|
||||
icon={<Eye size={17} />}
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="今日 UV"
|
||||
value={formatInt(data.today_uv)}
|
||||
sub={`${delta(data.today_uv, data.yesterday_uv)} vs 昨日`}
|
||||
icon={<Users size={17} />}
|
||||
color="var(--ok)"
|
||||
soft="var(--ok-soft, var(--panel-2))"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="今日登录浏览"
|
||||
value={formatInt(data.logged_in_pv)}
|
||||
hint={
|
||||
data.today_pv > 0
|
||||
? `占今日 PV ${Math.round((data.logged_in_pv / data.today_pv) * 100)}%`
|
||||
: undefined
|
||||
}
|
||||
icon={<LogIn size={17} />}
|
||||
color="var(--gold)"
|
||||
soft="var(--gold-soft)"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="昨日 PV"
|
||||
value={formatInt(data.yesterday_pv)}
|
||||
hint={`昨日 UV ${formatInt(data.yesterday_uv)}`}
|
||||
icon={<CalendarDays size={17} />}
|
||||
color="var(--ink-2)"
|
||||
soft="var(--panel-2)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminChartCard title="PV / UV 趋势" hint="来自日聚合表,历史数据不随明细清理丢失">
|
||||
<VisitTrendChart data={data.trend} />
|
||||
</AdminChartCard>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<AdminChartCard title="设备分布" hint={`近 ${data.range_days} 日浏览`}>
|
||||
{devices.length ? <NamedPieChart data={devices} /> : <TableEmpty />}
|
||||
</AdminChartCard>
|
||||
<AdminChartCard title="来源占比" hint={`近 ${data.range_days} 日浏览`}>
|
||||
{sources.length ? <NamedBarChart data={sources} /> : <TableEmpty />}
|
||||
</AdminChartCard>
|
||||
</div>
|
||||
|
||||
<AdminChartCard title="热门页面 Top 10" hint={`近 ${data.range_days} 日`}>
|
||||
{data.top_pages.length ? (
|
||||
<div className="flex flex-col divide-y" style={{ borderColor: "var(--line)" }}>
|
||||
{data.top_pages.map((p: VisitPageRow, i) => (
|
||||
<div key={p.path} className="py-2 flex items-center gap-3 text-[13px]">
|
||||
<span className="w-5 text-right tabular-nums shrink-0" style={{ color: "var(--ink-3)" }}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate" style={{ color: "var(--ink)" }} title={p.path}>
|
||||
{p.path || "/"}
|
||||
</span>
|
||||
<span className="hidden sm:block w-32 h-1.5 rounded-full overflow-hidden shrink-0" style={{ background: "var(--panel-2)" }}>
|
||||
<span
|
||||
className="block h-full rounded-full"
|
||||
style={{ width: `${Math.min(100, p.share).toFixed(1)}%`, background: "var(--accent)" }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-16 text-right tabular-nums" style={{ color: "var(--ink-2)" }}>
|
||||
{formatInt(p.pv)}
|
||||
</span>
|
||||
<span className="w-14 text-right tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||
UV {formatInt(p.uv)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<TableEmpty text="暂无浏览数据" />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
232
frontend/app/admin/analytics/details/page.tsx
Normal file
232
frontend/app/admin/analytics/details/page.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { AdminPager, AdminSegmented } from "@/components/admin";
|
||||
import { AdminChartCard } from "@/components/admin/charts";
|
||||
import AdminSearchField from "@/components/admin/AdminSearchField";
|
||||
import { apiStatsEvents, type VisitEventItem, type VisitEventsResult } from "@/lib/api";
|
||||
import { StatsState, TableEmpty, deviceLabel, kindLabel, refKindLabel } from "../ui";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "pageview", label: "浏览" },
|
||||
{ key: "bot", label: "爬虫" },
|
||||
{ key: "probe", label: "探测" },
|
||||
];
|
||||
|
||||
/** 本地日期(yyyy-mm-dd)→ RFC3339,覆盖全天 */
|
||||
function dayToRFC3339(date: string, endOfDay = false): string {
|
||||
if (!date) return "";
|
||||
return new Date(`${date}T${endOfDay ? "23:59:59" : "00:00:00"}`).toISOString();
|
||||
}
|
||||
|
||||
function kindBadge(kind: string) {
|
||||
const map: Record<string, { color: string; soft: string }> = {
|
||||
pageview: { color: "var(--accent)", soft: "var(--accent-soft)" },
|
||||
bot: { color: "var(--gold)", soft: "var(--gold-soft)" },
|
||||
probe: { color: "var(--danger)", soft: "var(--danger-soft, var(--panel-2))" },
|
||||
};
|
||||
const s = map[kind] ?? { color: "var(--ink-3)", soft: "var(--panel-2)" };
|
||||
return (
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium shrink-0"
|
||||
style={{ color: s.color, background: s.soft }}
|
||||
>
|
||||
{kindLabel(kind)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsInner() {
|
||||
const searchParams = useSearchParams();
|
||||
// 初始筛选只读一次(如从「爬虫与安全」跳转 ?ip=x.x.x.x)
|
||||
const [initialIp] = useState(() => searchParams.get("ip") ?? "");
|
||||
|
||||
const [kind, setKind] = useState("");
|
||||
const [path, setPath] = useState("");
|
||||
const [ip, setIp] = useState(initialIp);
|
||||
const [bot, setBot] = useState("");
|
||||
const [start, setStart] = useState("");
|
||||
const [end, setEnd] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState<VisitEventsResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(
|
||||
(f: { kind: string; path: string; ip: string; bot: string; start: string; end: string }, p: number) => {
|
||||
setLoading(true);
|
||||
apiStatsEvents({
|
||||
kind: f.kind || undefined,
|
||||
path: f.path || undefined,
|
||||
ip: f.ip || undefined,
|
||||
bot: f.bot || undefined,
|
||||
start: dayToRFC3339(f.start) || undefined,
|
||||
end: dayToRFC3339(f.end, true) || undefined,
|
||||
page: p,
|
||||
size: PAGE_SIZE,
|
||||
})
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setError("");
|
||||
})
|
||||
.catch(() => setError("加载失败,请稍后重试"))
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load({ kind, path, ip, bot, start, end }, page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [load, kind, path, ip, bot, start, end, page]);
|
||||
|
||||
const resetPage = () => setPage(1);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="meta text-[12.5px]">
|
||||
记录含浏览、爬虫与异常请求;访客标识仅存哈希,IP 与登录日志口径一致,仅超管可见。
|
||||
</p>
|
||||
|
||||
<AdminChartCard title="访问明细" hint={`共 ${data ? data.total.toLocaleString("zh-CN") : "…"} 条记录`}>
|
||||
<div className="flex flex-col gap-2.5 mb-4">
|
||||
<AdminSegmented options={KIND_OPTIONS} value={kind} onChange={(k) => { setKind(k); resetPage(); }} ariaLabel="事件类型筛选" role="group" size="sm" />
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="w-full sm:w-56">
|
||||
<AdminSearchField
|
||||
value={path}
|
||||
onChange={(v) => { setPath(v); }}
|
||||
onSubmit={resetPage}
|
||||
onClear={() => { setPath(""); resetPage(); }}
|
||||
placeholder="路径模糊匹配,回车应用"
|
||||
ariaLabel="按路径筛选"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-44">
|
||||
<AdminSearchField
|
||||
value={ip}
|
||||
onChange={setIp}
|
||||
onSubmit={resetPage}
|
||||
onClear={() => { setIp(""); resetPage(); }}
|
||||
placeholder="按 IP 筛选,回车应用"
|
||||
ariaLabel="按 IP 筛选"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-40">
|
||||
<AdminSearchField
|
||||
value={bot}
|
||||
onChange={setBot}
|
||||
onSubmit={resetPage}
|
||||
onClear={() => { setBot(""); resetPage(); }}
|
||||
placeholder="按爬虫名筛选"
|
||||
ariaLabel="按爬虫名筛选"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
从
|
||||
<input
|
||||
type="date"
|
||||
value={start}
|
||||
onChange={(e) => { setStart(e.target.value); resetPage(); }}
|
||||
className="admin-field-input"
|
||||
style={{ width: 150 }}
|
||||
aria-label="开始日期"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
至
|
||||
<input
|
||||
type="date"
|
||||
value={end}
|
||||
onChange={(e) => { setEnd(e.target.value); resetPage(); }}
|
||||
className="admin-field-input"
|
||||
style={{ width: 150 }}
|
||||
aria-label="结束日期"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <StatsState error={error} /> : null}
|
||||
|
||||
{data && data.items.length ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]" style={{ minWidth: 860 }}>
|
||||
<thead>
|
||||
<tr className="text-left" style={{ color: "var(--ink-3)" }}>
|
||||
<th className="py-2 pr-3 font-medium whitespace-nowrap">时间</th>
|
||||
<th className="py-2 pr-3 font-medium">类型</th>
|
||||
<th className="py-2 pr-3 font-medium">路径</th>
|
||||
<th className="py-2 pr-3 font-medium">来源</th>
|
||||
<th className="py-2 pr-3 font-medium whitespace-nowrap">设备 / 系统 / 浏览器</th>
|
||||
<th className="py-2 pr-3 font-medium">用户</th>
|
||||
<th className="py-2 pr-3 font-medium">IP</th>
|
||||
<th className="py-2 font-medium text-right">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((e: VisitEventItem) => (
|
||||
<tr key={e.id} style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<td className="py-2 pr-3 whitespace-nowrap tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||
{new Date(e.created_at).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
{kindBadge(e.kind)}
|
||||
{e.bot ? (
|
||||
<span className="ml-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||
{e.bot}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="py-2 pr-3 max-w-[220px] truncate" style={{ color: "var(--ink)" }} title={e.path}>
|
||||
{e.path || "/"}
|
||||
</td>
|
||||
<td className="py-2 pr-3 whitespace-nowrap" style={{ color: "var(--ink-3)" }} title={e.ref_host}>
|
||||
{e.ref_host ? (
|
||||
<>
|
||||
{e.ref_host}
|
||||
<span className="ml-1 text-[11px]">{refKindLabel(e.ref_kind)}</span>
|
||||
</>
|
||||
) : (
|
||||
refKindLabel(e.ref_kind)
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 whitespace-nowrap" style={{ color: "var(--ink-2)" }}>
|
||||
{deviceLabel(e.device)} · {e.os || "?"} · {e.browser || "?"}
|
||||
</td>
|
||||
<td className="py-2 pr-3 whitespace-nowrap" style={{ color: "var(--ink-2)" }}>
|
||||
{e.username || <span style={{ color: "var(--ink-3)" }}>游客</span>}
|
||||
</td>
|
||||
<td className="py-2 pr-3 tabular-nums" style={{ color: "var(--ink-2)" }}>
|
||||
{e.ip || "—"}
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums" style={{ color: e.status >= 400 ? "var(--danger)" : "var(--ink-3)" }}>
|
||||
{e.status > 0 ? e.status : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<AdminPager page={page} total={data.total} pageSize={PAGE_SIZE} onChange={setPage} loading={loading} />
|
||||
</>
|
||||
) : (
|
||||
<TableEmpty text={loading ? "正在加载…" : "没有匹配的记录"} />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DetailsPage() {
|
||||
return (
|
||||
<Suspense fallback={<StatsState loading />}>
|
||||
<DetailsInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
15
frontend/app/admin/analytics/layout.tsx
Normal file
15
frontend/app/admin/analytics/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isSuperOrOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import AnalyticsShell from "./AnalyticsShell";
|
||||
|
||||
export default async function AnalyticsLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
||||
return <Forbidden text="访问统计仅超级管理员和站长可查看。" />;
|
||||
}
|
||||
return <AnalyticsShell>{children}</AnalyticsShell>;
|
||||
}
|
||||
17
frontend/app/admin/analytics/page.tsx
Normal file
17
frontend/app/admin/analytics/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { fetchStatsOverview, type VisitOverview } from "@/lib/api";
|
||||
import OverviewClient from "./OverviewClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AnalyticsOverviewPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
let initial: VisitOverview | null = null;
|
||||
try {
|
||||
initial = await fetchStatsOverview(cookie || undefined, "7d");
|
||||
} catch {
|
||||
initial = null;
|
||||
}
|
||||
return <OverviewClient initial={initial} />;
|
||||
}
|
||||
120
frontend/app/admin/analytics/pages/page.tsx
Normal file
120
frontend/app/admin/analytics/pages/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AdminPager } from "@/components/admin";
|
||||
import { AdminChartCard } from "@/components/admin/charts";
|
||||
import AdminSearchField from "@/components/admin/AdminSearchField";
|
||||
import { apiStatsPages, type VisitPages } from "@/lib/api";
|
||||
import { RangeSegmented, StatsState, TableEmpty, formatInt } from "../ui";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function PagesPage() {
|
||||
const [range, setRange] = useState<"7d" | "30d">("7d");
|
||||
const [q, setQ] = useState("");
|
||||
const [applied, setApplied] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState<VisitPages | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(
|
||||
(r: "7d" | "30d", query: string, p: number) => {
|
||||
setLoading(true);
|
||||
apiStatsPages(r, query, p, PAGE_SIZE)
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setError("");
|
||||
})
|
||||
.catch(() => setError("加载失败,请稍后重试"))
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load(range, applied, page);
|
||||
}, [load, range, applied, page]);
|
||||
|
||||
const switchRange = (r: "7d" | "30d") => {
|
||||
setRange(r);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitSearch = () => {
|
||||
setApplied(q.trim());
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setQ("");
|
||||
setApplied("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
if (!data) return <StatsState loading={loading} error={error || undefined} />;
|
||||
|
||||
const maxPv = data.items[0]?.pv ?? 1;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p className="meta text-[12.5px]">
|
||||
共 {formatInt(data.total)} 个不同路径被访问(近 {data.range_days} 日)
|
||||
</p>
|
||||
<RangeSegmented value={range} onChange={switchRange} />
|
||||
</div>
|
||||
|
||||
{error ? <StatsState error={error} /> : null}
|
||||
|
||||
<AdminChartCard
|
||||
title="受访页面"
|
||||
hint="按 PV 降序;搜索支持路径模糊匹配"
|
||||
actions={
|
||||
<div className="w-full sm:w-64">
|
||||
<AdminSearchField
|
||||
value={q}
|
||||
onChange={setQ}
|
||||
onSubmit={submitSearch}
|
||||
onClear={clearSearch}
|
||||
placeholder="搜索路径…"
|
||||
ariaLabel="搜索受访路径"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{data.items.length ? (
|
||||
<>
|
||||
<div className="flex flex-col divide-y" style={{ borderColor: "var(--line)" }}>
|
||||
{data.items.map((p) => (
|
||||
<div key={p.path} className="py-2.5 flex items-center gap-3 text-[13px]">
|
||||
<span className="flex-1 min-w-0 truncate font-medium" style={{ color: "var(--ink)" }} title={p.path}>
|
||||
{p.path || "/"}
|
||||
</span>
|
||||
<span className="hidden sm:block w-36 h-1.5 rounded-full overflow-hidden shrink-0" style={{ background: "var(--panel-2)" }}>
|
||||
<span
|
||||
className="block h-full rounded-full"
|
||||
style={{ width: `${Math.max(2, (p.pv / maxPv) * 100).toFixed(1)}%`, background: "var(--accent)" }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-16 text-right tabular-nums" style={{ color: "var(--ink-2)" }}>
|
||||
{formatInt(p.pv)}
|
||||
</span>
|
||||
<span className="w-16 text-right tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||
UV {formatInt(p.uv)}
|
||||
</span>
|
||||
<span className="w-12 text-right tabular-nums text-[11.5px]" style={{ color: "var(--ink-3)" }}>
|
||||
{p.share.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<AdminPager page={page} total={data.total} pageSize={PAGE_SIZE} onChange={setPage} loading={loading} />
|
||||
</>
|
||||
) : (
|
||||
<TableEmpty text={applied ? "没有匹配的页面" : "暂无浏览数据"} />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
frontend/app/admin/analytics/realtime/page.tsx
Normal file
118
frontend/app/admin/analytics/realtime/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Activity, Zap, Users, Clock } from "lucide-react";
|
||||
import { AdminStatCard } from "@/components/admin";
|
||||
import { AdminChartCard, SingleAreaChart } from "@/components/admin/charts";
|
||||
import { apiStatsRealtime, type VisitRealtime } from "@/lib/api";
|
||||
import { CardShell, StatsState, TableEmpty, deviceLabel, formatInt, kindLabel, usePolling } from "../ui";
|
||||
|
||||
export default function RealtimePage() {
|
||||
const [data, setData] = useState<VisitRealtime | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(() => {
|
||||
apiStatsRealtime()
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setError("");
|
||||
})
|
||||
.catch(() => setError("加载失败,请稍后重试"));
|
||||
}, []);
|
||||
usePolling(load, 15_000);
|
||||
|
||||
if (!data) return <StatsState loading={!error} error={error || undefined} />;
|
||||
|
||||
// 兜底:旧后端可能返回 null(Go nil 切片)
|
||||
const minutes = (data.minutes ?? []).map((m) => ({
|
||||
// SingleAreaChart 取 date 字段画 X 轴:只留 HH:MM
|
||||
date: m.minute.length >= 16 ? m.minute.slice(11) : m.minute,
|
||||
pv: m.pv,
|
||||
}));
|
||||
const topPages = data.top_pages ?? [];
|
||||
const recent = data.recent ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="meta text-[12.5px]">每 15 秒自动刷新;切回前台标签页时立即刷新。</p>
|
||||
{error ? <StatsState error={error} /> : null}
|
||||
|
||||
<div className="grid gap-3 grid-cols-2 xl:grid-cols-4">
|
||||
<AdminStatCard label="当前在线" value={formatInt(data.online)} hint="近 5 分钟活跃用户" icon={<Users size={17} />} />
|
||||
<AdminStatCard
|
||||
label="近 5 分钟 PV"
|
||||
value={formatInt(data.pv_5min)}
|
||||
icon={<Zap size={17} />}
|
||||
color="var(--ok)"
|
||||
soft="var(--ok-soft, var(--panel-2))"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="近 30 分钟 PV"
|
||||
value={formatInt(data.pv_30min)}
|
||||
icon={<Activity size={17} />}
|
||||
color="var(--gold)"
|
||||
soft="var(--gold-soft)"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="数据延迟"
|
||||
value="< 3s"
|
||||
hint="事件攒批落库周期"
|
||||
icon={<Clock size={17} />}
|
||||
color="var(--ink-2)"
|
||||
soft="var(--panel-2)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminChartCard title="近 60 分钟浏览" hint="分钟级 PV">
|
||||
{minutes.length ? (
|
||||
<SingleAreaChart data={minutes} dataKey="pv" name="PV" />
|
||||
) : (
|
||||
<TableEmpty text="暂无浏览数据" />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<CardShell title="近 5 分钟热门路径">
|
||||
{topPages.length ? (
|
||||
<ul className="flex flex-col divide-y" style={{ borderColor: "var(--line)" }}>
|
||||
{topPages.map((p) => (
|
||||
<li key={p.key} className="py-2 flex items-center gap-3 text-[13px]">
|
||||
<span className="flex-1 min-w-0 truncate" style={{ color: "var(--ink)" }}>
|
||||
{p.key || "/"}
|
||||
</span>
|
||||
<span className="tabular-nums shrink-0" style={{ color: "var(--ink-2)" }}>
|
||||
{formatInt(p.count)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<TableEmpty />
|
||||
)}
|
||||
</CardShell>
|
||||
|
||||
<CardShell title="最新浏览" hint="最多 20 条">
|
||||
{recent.length ? (
|
||||
<ul className="flex flex-col divide-y max-h-[420px] overflow-y-auto" style={{ borderColor: "var(--line)" }}>
|
||||
{recent.map((e) => (
|
||||
<li key={e.id} className="py-2 flex items-center gap-2.5 text-[13px]">
|
||||
<span className="w-[4.5rem] shrink-0 tabular-nums text-[11.5px]" style={{ color: "var(--ink-3)" }}>
|
||||
{new Date(e.created_at).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate" style={{ color: "var(--ink)" }} title={e.path}>
|
||||
{e.path || "/"}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11.5px]" style={{ color: "var(--ink-3)" }}>
|
||||
{deviceLabel(e.device)} · {kindLabel(e.kind)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<TableEmpty />
|
||||
)}
|
||||
</CardShell>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
frontend/app/admin/analytics/security/page.tsx
Normal file
127
frontend/app/admin/analytics/security/page.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Bot, ShieldAlert, KeyRound, Radar } from "lucide-react";
|
||||
import { AdminStatCard } from "@/components/admin";
|
||||
import { AdminChartCard, NamedBarChart, SingleAreaChart } from "@/components/admin/charts";
|
||||
import { apiStatsSecurity, type VisitSecurity } from "@/lib/api";
|
||||
import { RangeSegmented, StatsState, TableEmpty, formatInt } from "../ui";
|
||||
|
||||
export default function SecurityPage() {
|
||||
const [range, setRange] = useState<"7d" | "30d">("7d");
|
||||
const [data, setData] = useState<VisitSecurity | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
apiStatsSecurity(range)
|
||||
.then((d) => {
|
||||
if (alive) {
|
||||
setData(d);
|
||||
setError("");
|
||||
}
|
||||
})
|
||||
.catch(() => alive && setError("加载失败,请稍后重试"))
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [range]);
|
||||
|
||||
if (!data) return <StatsState loading={loading} error={error || undefined} />;
|
||||
|
||||
const botTrend = data.bot_trend.map((d) => ({ date: d.date, pv: d.pv }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p className="meta text-[12.5px]">爬虫与异常探测请求仅记录明细,不计入 PV/UV 统计。</p>
|
||||
<RangeSegmented value={range} onChange={setRange} />
|
||||
</div>
|
||||
|
||||
{error ? <StatsState error={error} /> : null}
|
||||
|
||||
<div className="grid gap-3 grid-cols-2 xl:grid-cols-4">
|
||||
<AdminStatCard label="爬虫请求" value={formatInt(data.bots.reduce((s, b) => s + b.count, 0))} icon={<Bot size={17} />} />
|
||||
<AdminStatCard
|
||||
label="异常探测"
|
||||
value={formatInt(data.probe_status.reduce((s, b) => s + b.count, 0))}
|
||||
hint="状态码 ≥400 的请求"
|
||||
icon={<Radar size={17} />}
|
||||
color="var(--gold)"
|
||||
soft="var(--gold-soft)"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="登录失败"
|
||||
value={formatInt(data.login_failed)}
|
||||
sub={`${formatInt(data.failed_users)} 个账号`}
|
||||
icon={<KeyRound size={17} />}
|
||||
color="var(--ink-2)"
|
||||
soft="var(--panel-2)"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="可疑 IP"
|
||||
value={formatInt(data.probe_ips.length)}
|
||||
hint="按探测次数排序"
|
||||
icon={<ShieldAlert size={17} />}
|
||||
color="var(--danger)"
|
||||
soft="var(--danger-soft, var(--panel-2))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<AdminChartCard title="爬虫分布" hint={`近 ${data.range_days} 日各爬虫请求量`}>
|
||||
{data.bots.length ? (
|
||||
<NamedBarChart data={data.bots.map((b) => ({ label: b.key, count: b.count }))} />
|
||||
) : (
|
||||
<TableEmpty text="暂无爬虫记录" />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
<AdminChartCard title="爬虫趋势" hint="每日爬虫请求量">
|
||||
{botTrend.length ? (
|
||||
<SingleAreaChart data={botTrend} dataKey="pv" name="爬虫请求" color="var(--gold)" />
|
||||
) : (
|
||||
<TableEmpty />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<AdminChartCard title="探测状态码分布" hint="状态码 ≥400 的请求">
|
||||
{data.probe_status.length ? (
|
||||
<NamedBarChart data={data.probe_status.map((s) => ({ label: s.key, count: s.count }))} />
|
||||
) : (
|
||||
<TableEmpty text="暂无异常探测" />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
<AdminChartCard title="可疑 IP Top 20" hint="点击「查明细」查看该 IP 全部访问记录">
|
||||
{data.probe_ips.length ? (
|
||||
<ul className="flex flex-col divide-y max-h-[360px] overflow-y-auto" style={{ borderColor: "var(--line)" }}>
|
||||
{data.probe_ips.map((ip) => (
|
||||
<li key={ip.key} className="py-2 flex items-center gap-3 text-[13px]">
|
||||
<span className="flex-1 min-w-0 truncate tabular-nums" style={{ color: "var(--ink)" }}>
|
||||
{ip.key}
|
||||
</span>
|
||||
<span className="tabular-nums shrink-0" style={{ color: "var(--ink-2)" }}>
|
||||
{formatInt(ip.count)}
|
||||
</span>
|
||||
<Link
|
||||
href={`/admin/analytics/details?ip=${encodeURIComponent(ip.key)}`}
|
||||
className="btn btn-line btn-sm shrink-0"
|
||||
>
|
||||
查明细
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<TableEmpty text="暂无可疑 IP" />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
frontend/app/admin/analytics/settings/page.tsx
Normal file
196
frontend/app/admin/analytics/settings/page.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Database, HardDrive, Trash2, Activity } from "lucide-react";
|
||||
import { AdminSettingRow, AdminStatCard, AdminSwitch } from "@/components/admin";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { apiStatsPurge, apiStatsSaveSettings, apiStatsSettings, type VisitSettingsPayload } from "@/lib/api";
|
||||
import { StatsState, formatBytes, formatInt } from "../ui";
|
||||
|
||||
/** 保留天数输入边界(与后端 service 一致) */
|
||||
const RETENTION_MIN = 7;
|
||||
const RETENTION_MAX = 365;
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [data, setData] = useState<VisitSettingsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [retention, setRetention] = useState(90);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savedMsg, setSavedMsg] = useState("");
|
||||
// 立即清理:天数输入 + 确认弹窗 + 受理提示
|
||||
const [purgeDays, setPurgeDays] = useState(90);
|
||||
const [purgeOpen, setPurgeOpen] = useState(false);
|
||||
const [purging, setPurging] = useState(false);
|
||||
const [purgeMsg, setPurgeMsg] = useState("");
|
||||
|
||||
const load = useCallback(() => {
|
||||
apiStatsSettings()
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setEnabled(d.enabled);
|
||||
setRetention(d.retention_days);
|
||||
setPurgeDays(d.retention_days);
|
||||
setError("");
|
||||
})
|
||||
.catch(() => setError("加载失败,请稍后重试"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const save = (nextEnabled: boolean, nextRetention: number) => {
|
||||
setSaving(true);
|
||||
setSavedMsg("");
|
||||
apiStatsSaveSettings(nextEnabled, nextRetention)
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setEnabled(d.enabled);
|
||||
setRetention(d.retention_days);
|
||||
setSavedMsg("已保存");
|
||||
setTimeout(() => setSavedMsg(""), 2000);
|
||||
})
|
||||
.catch(() => setError("保存失败,请稍后重试"))
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const toggleEnabled = () => {
|
||||
if (saving) return;
|
||||
save(!enabled, retention);
|
||||
};
|
||||
|
||||
const submitRetention = () => {
|
||||
if (saving) return;
|
||||
const v = Math.min(RETENTION_MAX, Math.max(RETENTION_MIN, Math.round(Number(retention) || 90)));
|
||||
setRetention(v);
|
||||
save(enabled, v);
|
||||
};
|
||||
|
||||
const doPurge = () => {
|
||||
setPurging(true);
|
||||
apiStatsPurge(purgeDays)
|
||||
.then(() => {
|
||||
setPurgeMsg(`清理任务已受理(删除 ${purgeDays} 天前的明细),稍后刷新本页查看容量变化。`);
|
||||
setTimeout(load, 3000);
|
||||
})
|
||||
.catch(() => setPurgeMsg("清理请求失败,请稍后重试。"))
|
||||
.finally(() => {
|
||||
setPurging(false);
|
||||
setPurgeOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (!data) return <StatsState loading={loading} error={error || undefined} />;
|
||||
|
||||
const clampPurge = (v: number) =>
|
||||
Math.min(RETENTION_MAX, Math.max(1, Math.round(Number(v) || 1)));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{error ? <StatsState error={error} /> : null}
|
||||
{purgeMsg ? (
|
||||
<div className="panel p-4 text-[13px]" style={{ color: "var(--ink-2)" }}>
|
||||
{purgeMsg}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 grid-cols-2 xl:grid-cols-4">
|
||||
<AdminStatCard label="明细行数" value={formatInt(data.usage.rows)} icon={<Database size={17} />} />
|
||||
<AdminStatCard
|
||||
label="磁盘占用"
|
||||
value={formatBytes(data.usage.bytes)}
|
||||
hint="含索引(pg_total_relation_size)"
|
||||
icon={<HardDrive size={17} />}
|
||||
color="var(--ok)"
|
||||
soft="var(--ok-soft, var(--panel-2))"
|
||||
/>
|
||||
<AdminStatCard label="保留天数" value={`${data.retention_days} 天`} hint="超期明细每日 04:30 自动清理" icon={<Activity size={17} />} color="var(--gold)" soft="var(--gold-soft)" />
|
||||
<AdminStatCard
|
||||
label="采集丢弃"
|
||||
value={formatInt(data.dropped)}
|
||||
hint="写入队列满时丢弃的事件数(进程启动以来)"
|
||||
icon={<Trash2 size={17} />}
|
||||
color="var(--ink-2)"
|
||||
soft="var(--panel-2)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminSettingRow
|
||||
title="访问统计采集"
|
||||
description="关闭后前端埋点与后端捕获中间件立即停止写入(返回 204),已有数据保留。"
|
||||
control={<AdminSwitch checked={enabled} disabled={saving} onChange={toggleEnabled} label="访问统计采集开关" />}
|
||||
/>
|
||||
|
||||
<AdminSettingRow
|
||||
title="明细保留期"
|
||||
description={`超过保留期的访问明细每日 04:30 自动分批清理;日聚合趋势不受影响。范围 ${RETENTION_MIN}–${RETENTION_MAX} 天。`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<input
|
||||
type="number"
|
||||
min={RETENTION_MIN}
|
||||
max={RETENTION_MAX}
|
||||
value={retention}
|
||||
onChange={(e) => setRetention(Number(e.target.value))}
|
||||
onBlur={submitRetention}
|
||||
onKeyDown={(e) => e.key === "Enter" && submitRetention()}
|
||||
className="admin-field-input"
|
||||
style={{ width: 120 }}
|
||||
aria-label="明细保留天数"
|
||||
/>
|
||||
<span className="text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||||
天
|
||||
</span>
|
||||
{saving ? <span className="meta text-[12px]">保存中…</span> : null}
|
||||
{savedMsg ? (
|
||||
<span className="text-[12px]" style={{ color: "var(--ok)" }}>
|
||||
{savedMsg}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</AdminSettingRow>
|
||||
|
||||
<AdminSettingRow
|
||||
title="立即清理"
|
||||
description="手动删除指定天数之前的明细记录,异步执行,不影响正常访问。"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={RETENTION_MAX}
|
||||
value={purgeDays}
|
||||
onChange={(e) => setPurgeDays(clampPurge(Number(e.target.value)))}
|
||||
className="admin-field-input"
|
||||
style={{ width: 120 }}
|
||||
aria-label="清理多少天前的明细"
|
||||
/>
|
||||
<span className="text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||||
天前的明细
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => setPurgeOpen(true)}
|
||||
disabled={purging}
|
||||
>
|
||||
<Trash2 size={14} /> 立即清理
|
||||
</button>
|
||||
</div>
|
||||
</AdminSettingRow>
|
||||
|
||||
<ConfirmDialog
|
||||
open={purgeOpen}
|
||||
title="确认立即清理"
|
||||
message={`将删除 ${purgeDays} 天前的全部访问明细(当前共 ${formatInt(data.usage.rows)} 行)。该操作不可恢复,日聚合趋势不受影响。确认执行?`}
|
||||
confirmLabel="确认清理"
|
||||
danger
|
||||
busy={purging}
|
||||
onConfirm={doPurge}
|
||||
onCancel={() => setPurgeOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
frontend/app/admin/analytics/sources/page.tsx
Normal file
111
frontend/app/admin/analytics/sources/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AdminChartCard, NamedBarChart, NamedPieChart } from "@/components/admin/charts";
|
||||
import { apiStatsSources, type VisitSources } from "@/lib/api";
|
||||
import { RangeSegmented, StatsState, TableEmpty, refKindLabel } from "../ui";
|
||||
|
||||
/** 来源按日表:固定展示四类主要来源列 */
|
||||
const TREND_KINDS = ["direct", "search", "social", "external"] as const;
|
||||
|
||||
export default function SourcesPage() {
|
||||
const [range, setRange] = useState<"7d" | "30d">("7d");
|
||||
const [data, setData] = useState<VisitSources | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
apiStatsSources(range)
|
||||
.then((d) => {
|
||||
if (alive) {
|
||||
setData(d);
|
||||
setError("");
|
||||
}
|
||||
})
|
||||
.catch(() => alive && setError("加载失败,请稍后重试"))
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [range]);
|
||||
|
||||
if (!data) return <StatsState loading={loading} error={error || undefined} />;
|
||||
|
||||
const kinds = data.kinds.map((k) => ({ label: refKindLabel(k.key), count: k.count }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p className="meta text-[12.5px]">按浏览来源(Referer)自动分类;爬虫与探测请求不计入。</p>
|
||||
<RangeSegmented value={range} onChange={setRange} />
|
||||
</div>
|
||||
|
||||
{error ? <StatsState error={error} /> : null}
|
||||
|
||||
<AdminChartCard title="来源类别占比" hint={`近 ${data.range_days} 日浏览`}>
|
||||
{kinds.length ? <NamedPieChart data={kinds} /> : <TableEmpty />}
|
||||
</AdminChartCard>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<AdminChartCard title="搜索引擎明细" hint="来自搜索结果页的浏览 Top 10">
|
||||
{data.search_hosts.length ? (
|
||||
<NamedBarChart data={data.search_hosts.map((h) => ({ label: h.key, count: h.count }))} />
|
||||
) : (
|
||||
<TableEmpty text="暂无搜索引擎来源" />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
<AdminChartCard title="外部链接明细" hint="其他站点跳转来源 Top 10">
|
||||
{data.external.length ? (
|
||||
<NamedBarChart data={data.external.map((h) => ({ label: h.key, count: h.count }))} />
|
||||
) : (
|
||||
<TableEmpty text="暂无外链来源" />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
</div>
|
||||
|
||||
<AdminChartCard title="来源按日分布" hint="各来源类别每日浏览量">
|
||||
{data.trend.length ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="text-left" style={{ color: "var(--ink-3)" }}>
|
||||
<th className="py-2 pr-3 font-medium">日期</th>
|
||||
{TREND_KINDS.map((k) => (
|
||||
<th key={k} className="py-2 pr-3 font-medium text-right">
|
||||
{refKindLabel(k)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.trend.map((row) => {
|
||||
const byKey = new Map(row.items.map((i) => [i.key, i.count]));
|
||||
return (
|
||||
<tr key={row.date} style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<td className="py-2 pr-3 tabular-nums" style={{ color: "var(--ink-2)" }}>
|
||||
{row.date.slice(0, 10)}
|
||||
</td>
|
||||
{TREND_KINDS.map((k) => (
|
||||
<td key={k} className="py-2 pr-3 text-right tabular-nums" style={{ color: "var(--ink)" }}>
|
||||
{(byKey.get(k) ?? 0) === 0 ? (
|
||||
<span style={{ color: "var(--ink-3)" }}>—</span>
|
||||
) : (
|
||||
(byKey.get(k) ?? 0).toLocaleString("zh-CN")
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<TableEmpty />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
frontend/app/admin/analytics/ui.tsx
Normal file
166
frontend/app/admin/analytics/ui.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||
|
||||
/** 事件类型中文标签 */
|
||||
export function kindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "pageview":
|
||||
return "浏览";
|
||||
case "bot":
|
||||
return "爬虫";
|
||||
case "probe":
|
||||
return "探测";
|
||||
default:
|
||||
return kind || "-";
|
||||
}
|
||||
}
|
||||
|
||||
/** 来源类别中文标签 */
|
||||
export function refKindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "direct":
|
||||
return "直接访问";
|
||||
case "internal":
|
||||
return "站内";
|
||||
case "search":
|
||||
return "搜索引擎";
|
||||
case "social":
|
||||
return "社交";
|
||||
case "external":
|
||||
return "外部链接";
|
||||
default:
|
||||
return kind || "-";
|
||||
}
|
||||
}
|
||||
|
||||
export function deviceLabel(d: string): string {
|
||||
switch (d) {
|
||||
case "computer":
|
||||
return "电脑";
|
||||
case "mobile":
|
||||
return "手机";
|
||||
case "tablet":
|
||||
return "平板";
|
||||
default:
|
||||
return d || "未知";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatBytes(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let v = n;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function formatInt(n: number): string {
|
||||
return new Intl.NumberFormat("zh-CN").format(n);
|
||||
}
|
||||
|
||||
/** 简单加载/错误占位(居中卡片内文本) */
|
||||
export function StatsState({ loading, error }: { loading?: boolean; error?: string }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="panel p-10 text-center text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||||
正在加载…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="panel p-10 text-center text-[13px]" style={{ color: "var(--danger)" }}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TableEmpty({ text = "暂无数据" }: { text?: string }) {
|
||||
return (
|
||||
<p className="py-8 text-center text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** 15 秒轮询 + 回前台即拉;返回手动 reload */
|
||||
export function usePolling(load: () => void, intervalMs = 15_000) {
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = setInterval(load, intervalMs);
|
||||
const off = onDocumentVisible(load);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
off();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [intervalMs]);
|
||||
return () => setTick((t) => t + 1);
|
||||
}
|
||||
|
||||
/** 7/30 日区间切换段控件(各 Tab 复用) */
|
||||
export function RangeSegmented({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: "7d" | "30d";
|
||||
onChange: (v: "7d" | "30d") => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" role="tablist" aria-label="统计区间">
|
||||
{(
|
||||
[
|
||||
{ key: "7d", label: "近 7 日" },
|
||||
{ key: "30d", label: "近 30 日" },
|
||||
] as const
|
||||
).map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === o.key}
|
||||
onClick={() => value !== o.key && onChange(o.key)}
|
||||
className="admin-seg"
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardShell({
|
||||
title,
|
||||
hint,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="panel p-4 sm:p-5">
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{title}
|
||||
</h3>
|
||||
{hint ? <p className="meta text-[11.5px] mt-0.5">{hint}</p> : null}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
frontend/app/admin/analytics/visitors/page.tsx
Normal file
111
frontend/app/admin/analytics/visitors/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Users, UserPlus, Repeat, LogIn } from "lucide-react";
|
||||
import { AdminStatCard } from "@/components/admin";
|
||||
import { AdminChartCard, NamedBarChart, NamedPieChart } from "@/components/admin/charts";
|
||||
import { apiStatsVisitors, type VisitVisitors } from "@/lib/api";
|
||||
import { RangeSegmented, StatsState, TableEmpty, formatInt } from "../ui";
|
||||
|
||||
export default function VisitorsPage() {
|
||||
const [range, setRange] = useState<"7d" | "30d">("7d");
|
||||
const [data, setData] = useState<VisitVisitors | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
apiStatsVisitors(range)
|
||||
.then((d) => {
|
||||
if (alive) {
|
||||
setData(d);
|
||||
setError("");
|
||||
}
|
||||
})
|
||||
.catch(() => alive && setError("加载失败,请稍后重试"))
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [range]);
|
||||
|
||||
if (!data) return <StatsState loading={loading} error={error || undefined} />;
|
||||
|
||||
const devices = data.devices.map((d) => ({ label: d.label || "未知", count: d.count }));
|
||||
const oses = data.oses.map((d) => ({ label: d.label || "未知", count: d.count }));
|
||||
const browsers = data.browsers.map((d) => ({ label: d.label || "未知", count: d.count }));
|
||||
const hourly = data.hourly.map((h) => ({ label: `${h.key}时`, count: h.count }));
|
||||
const totalPv = data.logged_in_pv + data.guest_pv;
|
||||
const totalVisitors = data.new_visitors + data.returning;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p className="meta text-[12.5px]">基于匿名访客标识(仅存哈希)与登录用户统计。</p>
|
||||
<RangeSegmented value={range} onChange={setRange} />
|
||||
</div>
|
||||
|
||||
{error ? <StatsState error={error} /> : null}
|
||||
|
||||
<div className="grid gap-3 grid-cols-2 xl:grid-cols-4">
|
||||
<AdminStatCard label="登录用户浏览" value={formatInt(data.logged_in_pv)} icon={<LogIn size={17} />} />
|
||||
<AdminStatCard
|
||||
label="游客浏览"
|
||||
value={formatInt(data.guest_pv)}
|
||||
icon={<Users size={17} />}
|
||||
color="var(--ok)"
|
||||
soft="var(--ok-soft, var(--panel-2))"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="新访客"
|
||||
value={formatInt(data.new_visitors)}
|
||||
sub={totalVisitors > 0 ? `${Math.round((data.new_visitors / totalVisitors) * 100)}%` : undefined}
|
||||
hint="统计期内首次出现"
|
||||
icon={<UserPlus size={17} />}
|
||||
color="var(--gold)"
|
||||
soft="var(--gold-soft)"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="回访"
|
||||
value={formatInt(data.returning)}
|
||||
sub={totalVisitors > 0 ? `${Math.round((data.returning / totalVisitors) * 100)}%` : undefined}
|
||||
icon={<Repeat size={17} />}
|
||||
color="var(--ink-2)"
|
||||
soft="var(--panel-2)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{totalPv > 0 ? (
|
||||
<AdminChartCard title="登录 vs 游客" hint={`近 ${data.range_days} 日浏览占比`}>
|
||||
<NamedPieChart
|
||||
data={[
|
||||
{ label: "登录用户", count: data.logged_in_pv },
|
||||
{ label: "游客", count: data.guest_pv },
|
||||
]}
|
||||
/>
|
||||
</AdminChartCard>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<AdminChartCard title="设备分布">
|
||||
{devices.length ? <NamedPieChart data={devices} /> : <TableEmpty />}
|
||||
</AdminChartCard>
|
||||
<AdminChartCard title="操作系统 Top 8">
|
||||
{oses.length ? <NamedPieChart data={oses} /> : <TableEmpty />}
|
||||
</AdminChartCard>
|
||||
<AdminChartCard title="浏览器 Top 8">
|
||||
{browsers.length ? <NamedPieChart data={browsers} /> : <TableEmpty />}
|
||||
</AdminChartCard>
|
||||
</div>
|
||||
|
||||
<AdminChartCard title="24 小时活跃分布" hint="各小时浏览量(本地时区)">
|
||||
{hourly.length ? (
|
||||
<NamedBarChart data={hourly} />
|
||||
) : (
|
||||
<TableEmpty />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
824
frontend/app/admin/sidebar/SidebarAdmin.tsx
Normal file
824
frontend/app/admin/sidebar/SidebarAdmin.tsx
Normal file
@@ -0,0 +1,824 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
BarChart3,
|
||||
Blocks,
|
||||
ChevronDown,
|
||||
CircleDollarSign,
|
||||
Code2,
|
||||
FileText,
|
||||
Flame,
|
||||
Handshake,
|
||||
Home,
|
||||
Info,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Link2,
|
||||
List,
|
||||
Megaphone,
|
||||
PanelLeft,
|
||||
Pencil,
|
||||
PenLine,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
User,
|
||||
UserPlus,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiAdminCreateSidebarWidget,
|
||||
apiAdminDeleteSidebarWidget,
|
||||
apiAdminGetSidebar,
|
||||
apiAdminSaveSidebarConfig,
|
||||
apiAdminUpdateSidebarWidget,
|
||||
type SidebarBlockRef,
|
||||
type SidebarConfig,
|
||||
type SidebarScope,
|
||||
type SidebarWidgetRecord,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
SIDEBAR_BLOCK_ICON,
|
||||
SIDEBAR_MAX_BLOCKS_PER_SIDE,
|
||||
SIDEBAR_SCOPE_LABELS,
|
||||
SIDEBAR_WIDGET_META,
|
||||
defaultSidebarConfig,
|
||||
sidebarBlockLabel,
|
||||
} from "@/lib/sidebarMeta";
|
||||
import {
|
||||
AdminEmpty,
|
||||
AdminPageHeader,
|
||||
AdminSettingsPage,
|
||||
AdminSettingsWork,
|
||||
AdminSwitch,
|
||||
} from "@/components/admin";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import Modal from "@/components/Modal";
|
||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { mergeWidgetCode, parseMixedWidgetCode } from "@/lib/widgetCode";
|
||||
|
||||
type Side = "left" | "right";
|
||||
type SectionId = SidebarScope | "widgets";
|
||||
|
||||
const refKey = (r: SidebarBlockRef) => (r.widget ? `w:${r.widget}` : `k:${r.key}`);
|
||||
|
||||
// lucide icon 映射(SIDEBAR_BLOCK_ICON 是字符串 key,这里解析成组件)
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
"layout-grid": LayoutGrid,
|
||||
"bar-chart-3": BarChart3,
|
||||
megaphone: Megaphone,
|
||||
"circle-dollar-sign": CircleDollarSign,
|
||||
handshake: Handshake,
|
||||
flame: Flame,
|
||||
"user-plus": UserPlus,
|
||||
users: Users,
|
||||
link2: Link2,
|
||||
info: Info,
|
||||
"shield-check": ShieldCheck,
|
||||
"file-text": FileText,
|
||||
layers: Layers,
|
||||
user: User,
|
||||
"pen-line": PenLine,
|
||||
list: List,
|
||||
};
|
||||
function keyIcon(key: string): LucideIcon | undefined {
|
||||
return ICON_MAP[SIDEBAR_BLOCK_ICON[key] ?? ""];
|
||||
}
|
||||
|
||||
const SIDES: { scope: SidebarScope; icon: LucideIcon; desc: string; sides: Side[] }[] = [
|
||||
{ scope: "home", icon: Home, desc: "首页全站态(含板块页左栏):左右两栏均可增删排序", sides: ["left", "right"] },
|
||||
{ scope: "board", icon: Layers, desc: "板块页右栏;板块页左栏跟随「首页侧栏」配置", sides: ["right"] },
|
||||
{ scope: "post", icon: FileText, desc: "帖子详情页:左右两栏(窄屏不渲染侧栏)", sides: ["left", "right"] },
|
||||
];
|
||||
|
||||
const SIDE_LABEL: Record<Side, string> = { left: "左栏", right: "右栏" };
|
||||
const WIDGET_CODE_MAX_BYTES = 64 * 1024;
|
||||
|
||||
export default function SidebarAdmin({
|
||||
initialConfig,
|
||||
initialWidgets,
|
||||
initError,
|
||||
}: {
|
||||
initialConfig: SidebarConfig | null;
|
||||
initialWidgets: SidebarWidgetRecord[];
|
||||
initError: string;
|
||||
}) {
|
||||
const [config, setConfig] = useState<SidebarConfig>(() => initialConfig || defaultSidebarConfig());
|
||||
const [widgets, setWidgets] = useState<SidebarWidgetRecord[]>(initialWidgets);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState<SectionId>("home");
|
||||
const savedJsonRef = useRef(JSON.stringify(initialConfig || defaultSidebarConfig()));
|
||||
const dirty = JSON.stringify(config) !== savedJsonRef.current;
|
||||
const { leaveOpen, confirmLeave, cancelLeave } = useUnsavedGuard(dirty && !busy);
|
||||
|
||||
// 工具编辑弹层
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [draftName, setDraftName] = useState("");
|
||||
const [mixed, setMixed] = useState("");
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
|
||||
const draftBytes = useMemo(() => new TextEncoder().encode(mixed).length, [mixed]);
|
||||
const draftOk = draftName.trim().length > 0 && draftBytes <= WIDGET_CODE_MAX_BYTES;
|
||||
|
||||
const reloadAll = async () => {
|
||||
const res = await apiAdminGetSidebar();
|
||||
setConfig(res.config);
|
||||
setWidgets(res.widgets);
|
||||
savedJsonRef.current = JSON.stringify(res.config);
|
||||
};
|
||||
|
||||
/* ---------- 布局配置 ---------- */
|
||||
|
||||
const setSide = (scope: SidebarScope, side: Side, next: SidebarBlockRef[]) => {
|
||||
setConfig((c) => {
|
||||
const cur = c.scopes[scope] ?? {};
|
||||
return { ...c, scopes: { ...c.scopes, [scope]: { ...cur, [side]: next } } };
|
||||
});
|
||||
};
|
||||
|
||||
const moveAcross = (scope: SidebarScope, from: Side, index: number) => {
|
||||
const to: Side = from === "left" ? "right" : "left";
|
||||
setConfig((c) => {
|
||||
const cur = c.scopes[scope] ?? {};
|
||||
const fromList = [...(cur[from] ?? [])];
|
||||
const [item] = fromList.splice(index, 1);
|
||||
const toList = cur[to] ?? [];
|
||||
if (!item || toList.length >= SIDEBAR_MAX_BLOCKS_PER_SIDE) return c;
|
||||
return { ...c, scopes: { ...c.scopes, [scope]: { ...cur, [from]: fromList, [to]: [...toList, item] } } };
|
||||
});
|
||||
};
|
||||
|
||||
const resetScope = (scope: SidebarScope) => {
|
||||
const def = defaultSidebarConfig().scopes[scope] ?? {};
|
||||
setConfig((c) => ({
|
||||
...c,
|
||||
scopes: { ...c.scopes, [scope]: { left: def.left ?? [], right: def.right ?? [] } },
|
||||
}));
|
||||
toast("已重置为默认布局,记得保存", "ok");
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiAdminSaveSidebarConfig(config);
|
||||
setConfig(res.config);
|
||||
savedJsonRef.current = JSON.stringify(res.config);
|
||||
toast("侧栏布局已保存", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- 自定义工具 ---------- */
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
setDraftName("");
|
||||
setMixed("");
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (w: SidebarWidgetRecord) => {
|
||||
setEditingId(w.id);
|
||||
setDraftName(w.name);
|
||||
setMixed(mergeWidgetCode({ html: w.html, css: w.css, js: w.js }));
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const submitWidget = async () => {
|
||||
if (!draftOk) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const code = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
|
||||
if (editingId == null) {
|
||||
await apiAdminCreateSidebarWidget(code);
|
||||
toast("工具已创建", "ok");
|
||||
} else {
|
||||
const w = widgets.find((x) => x.id === editingId);
|
||||
await apiAdminUpdateSidebarWidget(editingId, { ...code, enabled: w?.enabled ?? true });
|
||||
toast("工具已保存", "ok");
|
||||
}
|
||||
setEditorOpen(false);
|
||||
await reloadAll();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleWidget = async (w: SidebarWidgetRecord) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiAdminUpdateSidebarWidget(w.id, { name: w.name, html: w.html, css: w.css, js: w.js, enabled: !w.enabled });
|
||||
toast(w.enabled ? "已停用" : "已启用", "ok");
|
||||
await reloadAll();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "操作失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteWidget = async () => {
|
||||
if (deleteId == null) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiAdminDeleteSidebarWidget(deleteId);
|
||||
toast("工具已删除,侧栏引用已同步清理", "ok");
|
||||
setDeleteId(null);
|
||||
await reloadAll();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "删除失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- 分区滚动导航 ---------- */
|
||||
|
||||
const sectionRefs = useRef<Record<SectionId, HTMLElement | null>>({ home: null, board: null, post: null, widgets: null });
|
||||
useEffect(() => {
|
||||
const els = Object.values(sectionRefs.current).filter((el): el is HTMLElement => !!el);
|
||||
if (els.length === 0) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.filter((e) => e.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio);
|
||||
if (visible[0]) setActiveSection((visible[0].target as HTMLElement).dataset.section as SectionId);
|
||||
},
|
||||
{ rootMargin: "-30% 0px -55% 0px", threshold: [0, 0.25, 0.5, 1] }
|
||||
);
|
||||
els.forEach((el) => observer.observe(el));
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const scrollTo = (id: SectionId) => sectionRefs.current[id]?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
|
||||
if (initError && !initialConfig) return <p className="meta px-1 py-6">{initError}</p>;
|
||||
|
||||
// 各范围块数(含左右两侧合计,用于导航徽章)
|
||||
const scopeCount = (s: SidebarScope) => {
|
||||
const sc = config.scopes[s] ?? {};
|
||||
return (sc.left ?? []).length + (sc.right ?? []).length;
|
||||
};
|
||||
const builtinCount = widgets.filter((w) => w.enabled).length;
|
||||
|
||||
const navItems: { id: SectionId; label: string; icon: LucideIcon; count: number }[] = [
|
||||
...SIDES.map((s) => ({ id: s.scope as SectionId, label: `${SIDEBAR_SCOPE_LABELS[s.scope]}侧栏`, icon: s.icon, count: scopeCount(s.scope) })),
|
||||
{ id: "widgets", label: "自定义工具", icon: Code2, count: builtinCount },
|
||||
];
|
||||
|
||||
const leftNav = (
|
||||
<nav className="admin-settings-nav" aria-label="侧栏管理分区">
|
||||
{navItems.map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => scrollTo(s.id)}
|
||||
aria-current={activeSection === s.id ? "page" : undefined}
|
||||
className="admin-settings-nav-link gap-2"
|
||||
>
|
||||
<Icon size={15} aria-hidden />
|
||||
<span className="flex-1 truncate text-left">{s.label}</span>
|
||||
{s.count > 0 && (
|
||||
<span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10.5px] font-semibold tabular-nums"
|
||||
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}>
|
||||
{s.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminSettingsPage>
|
||||
<AdminPageHeader
|
||||
icon={PanelLeft}
|
||||
title="侧栏管理"
|
||||
description="配置各页面左右侧栏的显示、顺序与位置,管理自定义 HTML 工具"
|
||||
/>
|
||||
|
||||
<AdminSettingsWork nav={leftNav}>
|
||||
<div className="space-y-5 pb-24">
|
||||
{SIDES.map(({ scope, icon, desc, sides }) => {
|
||||
const sc = config.scopes[scope] ?? {};
|
||||
const left = sc.left ?? [];
|
||||
const right = sc.right ?? [];
|
||||
const usedKeys = new Set([...left, ...right].map(refKey));
|
||||
const Icon = icon;
|
||||
return (
|
||||
<section
|
||||
key={scope}
|
||||
id={`sec-${scope}`}
|
||||
data-section={scope}
|
||||
ref={(el) => { sectionRefs.current[scope] = el; }}
|
||||
className="panel"
|
||||
>
|
||||
<SectionHead
|
||||
icon={<Icon size={16} />}
|
||||
title={`${SIDEBAR_SCOPE_LABELS[scope]}侧栏`}
|
||||
desc={desc}
|
||||
extra={
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => resetScope(scope)}>
|
||||
<RotateCcw size={13} /> 恢复默认
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className={`grid grid-cols-1 gap-5 p-4 sm:p-5 ${sides.length === 2 ? "lg:grid-cols-2" : ""}`}>
|
||||
{sides.map((side) => (
|
||||
<RailEditor
|
||||
key={side}
|
||||
scope={scope}
|
||||
side={side}
|
||||
list={side === "left" ? left : right}
|
||||
usedKeys={usedKeys}
|
||||
targetFull={(side === "left" ? right : left).length >= SIDEBAR_MAX_BLOCKS_PER_SIDE}
|
||||
widgets={widgets}
|
||||
onChange={(next) => setSide(scope, side, next)}
|
||||
onSendAcross={(i) => moveAcross(scope, side, i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 自定义工具 */}
|
||||
<section
|
||||
id="sec-widgets"
|
||||
data-section="widgets"
|
||||
ref={(el) => { sectionRefs.current.widgets = el; }}
|
||||
className="panel overflow-hidden"
|
||||
>
|
||||
<SectionHead
|
||||
icon={<Code2 size={16} />}
|
||||
title="自定义工具"
|
||||
desc="HTML + CSS + JS 代码块,前台原样渲染执行(不做消毒)"
|
||||
extra={<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}><Plus size={14} /> 新增工具</button>}
|
||||
/>
|
||||
<div className="p-4 sm:p-5">
|
||||
{widgets.length === 0 ? (
|
||||
<AdminEmpty icon={<Blocks size={22} />} title="还没有自定义工具" description="创建后可在上方各页面的侧栏中添加使用" />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
{widgets.map((w) => (
|
||||
<li key={w.id} className="j13-ads-row !py-2">
|
||||
<span
|
||||
className="shrink-0 w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
style={{ background: w.enabled ? "var(--gold-soft)" : "var(--panel-2)", color: w.enabled ? "var(--gold)" : "var(--ink-3)" }}
|
||||
aria-hidden
|
||||
>
|
||||
<Code2 size={14} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
{w.name}
|
||||
{!w.enabled && (
|
||||
<span className="ml-2 rounded-full px-1.5 py-0.5 text-[10.5px] font-semibold"
|
||||
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}>已停用</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="meta mt-0.5 text-[11.5px]">
|
||||
ID #{w.id} · HTML {w.html.length} / CSS {w.css.length} / JS {w.js.length} 字符
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex shrink-0 items-center gap-1.5 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
|
||||
<AdminSwitch label={`启用 ${w.name}`} checked={w.enabled} onChange={() => void toggleWidget(w)} />
|
||||
启用
|
||||
</label>
|
||||
<button type="button" className="btn btn-line btn-sm shrink-0" onClick={() => openEdit(w)}>
|
||||
<Pencil size={13} /> 编辑
|
||||
</button>
|
||||
<button type="button" className="btn btn-line btn-sm shrink-0" onClick={() => setDeleteId(w.id)} aria-label={`删除 ${w.name}`}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AdminSettingsWork>
|
||||
|
||||
{/* 吸底保存栏 */}
|
||||
{dirty && (
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 z-40 flex items-center justify-between gap-3 border-t px-4 py-3 sm:px-6"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--panel) 92%, transparent)",
|
||||
backdropFilter: "blur(12px)",
|
||||
borderColor: "var(--line)",
|
||||
}}
|
||||
>
|
||||
<p className="meta text-[12px]">
|
||||
<span className="inline-block w-2 h-2 rounded-full mr-1.5" style={{ background: "var(--gold)" }} aria-hidden />
|
||||
布局有未保存的修改
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void saveConfig()}>
|
||||
{busy ? "保存中…" : "保存布局"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工具编辑弹层 */}
|
||||
<Modal
|
||||
open={editorOpen}
|
||||
title={editingId == null ? "新增自定义工具" : "编辑自定义工具"}
|
||||
onClose={() => { if (!busy) setEditorOpen(false); }}
|
||||
busy={busy}
|
||||
maxWidthClass="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={() => setEditorOpen(false)}>取消</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !draftOk} onClick={() => void submitWidget()}>
|
||||
{busy ? "保存中…" : editingId == null ? "创建" : "保存"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="meta mb-1 block text-[12px]">名称(管理列表与侧栏引用中显示)</span>
|
||||
<input className="field w-full" value={draftName} maxLength={40} onChange={(e) => setDraftName(e.target.value)} placeholder="如:节假日横幅" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="meta text-[12px]">代码一框写完(<style> / <script> 段提交时自动拆分)</span>
|
||||
<span className="meta shrink-0 text-[11.5px] tabular-nums">{draftBytes} / {Math.floor(WIDGET_CODE_MAX_BYTES / 1024)} KB</span>
|
||||
</span>
|
||||
<textarea
|
||||
className="field w-full min-h-[240px] font-mono text-[12.5px]"
|
||||
value={mixed}
|
||||
onChange={(e) => setMixed(e.target.value)}
|
||||
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 页面加载后在工具位置执行\n</script>`}
|
||||
/>
|
||||
</label>
|
||||
<details className="rounded-xl border px-3 py-2" style={{ borderColor: "var(--line)" }}>
|
||||
<summary className="meta cursor-pointer select-none text-[12px]">开放能力速查:window.J13 / 主题变量</summary>
|
||||
<div className="mt-2 space-y-1.5 text-[12px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
|
||||
<p><code>J13.user</code>:当前登录用户(未登录为 null)</p>
|
||||
<p><code>J13.theme.get()</code> 返回 "light" | "dark";<code> J13.theme.onChange(fn)</code> 监听明暗切换</p>
|
||||
<p><code>J13.getOverview()</code>:站点公开统计(Promise,users / posts / online 等)</p>
|
||||
<p>CSS 变量自动跟随明暗主题与后台自定义主题色:<code>var(--ink) var(--accent) var(--panel)…</code></p>
|
||||
</div>
|
||||
</details>
|
||||
<p className="meta text-[11.5px]">
|
||||
CSS 自动限定在本板块内、不污染其他区域;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 删除工具确认 */}
|
||||
<ConfirmDialog
|
||||
open={deleteId != null}
|
||||
title="删除这个自定义工具?"
|
||||
message="删除后各侧栏中的引用将被同步清理,且不可恢复。"
|
||||
confirmLabel="删除"
|
||||
danger
|
||||
busy={busy}
|
||||
onConfirm={() => void deleteWidget()}
|
||||
onCancel={() => setDeleteId(null)}
|
||||
/>
|
||||
|
||||
{/* 未保存离开确认 */}
|
||||
<ConfirmDialog
|
||||
open={leaveOpen}
|
||||
title="有未保存的布局修改"
|
||||
message="离开页面将丢失未保存的侧栏布局修改。"
|
||||
confirmLabel="离开"
|
||||
onConfirm={confirmLeave}
|
||||
onCancel={cancelLeave}
|
||||
/>
|
||||
</AdminSettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 分区卡片头 ─── */
|
||||
function SectionHead({
|
||||
icon,
|
||||
title,
|
||||
desc,
|
||||
extra,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
desc?: string;
|
||||
extra?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 sm:px-5" style={{ borderBottom: "1px solid var(--line)" }}>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style={{ background: "var(--accent-soft)", color: "var(--accent)" }} aria-hidden>{icon}</span>
|
||||
<span className="min-w-0 leading-tight">
|
||||
<span className="block text-[15px] font-semibold" style={{ color: "var(--ink)" }}>{title}</span>
|
||||
{desc ? <span className="meta mt-0.5 block text-[12px] truncate">{desc}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
{extra ? <div className="shrink-0">{extra}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 单侧栏块列表编辑器 ─── */
|
||||
function RailEditor({
|
||||
scope,
|
||||
side,
|
||||
list,
|
||||
usedKeys,
|
||||
targetFull,
|
||||
widgets,
|
||||
onChange,
|
||||
onSendAcross,
|
||||
}: {
|
||||
scope: SidebarScope;
|
||||
side: Side;
|
||||
list: SidebarBlockRef[];
|
||||
usedKeys: Set<string>;
|
||||
targetFull: boolean;
|
||||
widgets: SidebarWidgetRecord[];
|
||||
onChange: (next: SidebarBlockRef[]) => void;
|
||||
onSendAcross: (index: number) => void;
|
||||
}) {
|
||||
const candidates = useMemo(() => {
|
||||
const meta = SIDEBAR_WIDGET_META[scope];
|
||||
const builtin: { ref: SidebarBlockRef; label: string; custom: boolean; iconKey?: string; widget?: SidebarWidgetRecord }[] = [];
|
||||
for (const key of Object.keys(meta)) {
|
||||
if (!usedKeys.has(`k:${key}`)) builtin.push({ ref: { key }, label: meta[key], custom: false, iconKey: SIDEBAR_BLOCK_ICON[key] });
|
||||
}
|
||||
const customWidgets: typeof builtin = [];
|
||||
for (const w of widgets) {
|
||||
if (!usedKeys.has(`w:${w.id}`)) customWidgets.push({ ref: { widget: w.id }, label: w.name, custom: true, widget: w });
|
||||
}
|
||||
return { builtin, customWidgets };
|
||||
}, [scope, usedKeys, widgets]);
|
||||
|
||||
const full = list.length >= SIDEBAR_MAX_BLOCKS_PER_SIDE;
|
||||
|
||||
const move = (index: number, delta: number) => {
|
||||
const next = [...list];
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= next.length) return;
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center justify-between gap-2">
|
||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
{SIDE_LABEL[side]}
|
||||
<span className="meta ml-2 text-[11.5px] font-normal tabular-nums">{list.length} / {SIDEBAR_MAX_BLOCKS_PER_SIDE}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{list.length === 0 ? (
|
||||
<div
|
||||
className="rounded-xl border border-dashed px-4 py-6 text-center"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-3)" }}
|
||||
>
|
||||
<Blocks size={18} className="mx-auto mb-2" aria-hidden style={{ color: "var(--ink-3)" }} />
|
||||
<p className="text-[12.5px]">该侧栏为空——前台不渲染这一列</p>
|
||||
<p className="meta mt-1 text-[11px]">从下方选择块添加</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{list.map((ref, i) => {
|
||||
const info = sidebarBlockLabel(ref, widgets.map((w) => ({ id: w.id, name: w.name })));
|
||||
const w = ref.widget ? widgets.find((x) => x.id === ref.widget) : null;
|
||||
const disabled = !!(w && !w.enabled);
|
||||
const Icon = ref.key ? keyIcon(ref.key) : undefined;
|
||||
return (
|
||||
<li
|
||||
key={`${refKey(ref)}-${i}`}
|
||||
className={`j13-ads-row !py-1.5 !px-2.5 ${disabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
{/* 类型图标圆点:内置蓝 / 自定义金 */}
|
||||
<span
|
||||
className="shrink-0 w-6 h-6 rounded-md flex items-center justify-center"
|
||||
style={{
|
||||
background: info?.custom ? "var(--gold-soft)" : "var(--accent-soft)",
|
||||
color: info?.custom ? "var(--gold)" : "var(--accent)",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{Icon ? <Icon size={12} /> : info?.custom ? <Code2 size={12} /> : <Blocks size={12} />}
|
||||
</span>
|
||||
{/* 序号 */}
|
||||
<span className="meta w-5 shrink-0 text-center text-[11px] tabular-nums">{i + 1}</span>
|
||||
{/* 块信息 */}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
{info ? info.label : "未知模块"}
|
||||
</span>
|
||||
<span className="meta text-[11px]">
|
||||
{info?.custom ? "自定义工具" : "内置模块"}
|
||||
{disabled ? " · 已停用(前台不显示)" : ""}
|
||||
</span>
|
||||
</span>
|
||||
{/* 操作按钮组 */}
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm !px-1.5 !py-1"
|
||||
disabled={i === 0}
|
||||
onClick={() => move(i, -1)}
|
||||
aria-label="上移"
|
||||
>
|
||||
<ArrowUp size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm !px-1.5 !py-1"
|
||||
disabled={i === list.length - 1}
|
||||
onClick={() => move(i, 1)}
|
||||
aria-label="下移"
|
||||
>
|
||||
<ArrowDown size={13} />
|
||||
</button>
|
||||
{scope !== "board" && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm !px-1.5 !py-1"
|
||||
disabled={targetFull}
|
||||
onClick={() => onSendAcross(i)}
|
||||
title={side === "left" ? "移到右栏末尾" : "移到左栏末尾"}
|
||||
aria-label={side === "left" ? "移到右栏" : "移到左栏"}
|
||||
>
|
||||
{side === "left" ? <ArrowRight size={13} /> : <ArrowLeft size={13} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm !px-1.5 !py-1"
|
||||
onClick={() => onChange(list.filter((_, j) => j !== i))}
|
||||
aria-label="从侧栏移除"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* 添加块:自定义下拉浮层(不依赖原生 select/option,完全跟随主题变量) */}
|
||||
<AddBlockPicker
|
||||
candidates={candidates}
|
||||
full={full}
|
||||
onPick={(ref) => onChange([...list, ref])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 添加块下拉浮层 ─── */
|
||||
type CandidateItem = { ref: SidebarBlockRef; label: string; custom: boolean; iconKey?: string; widget?: SidebarWidgetRecord };
|
||||
|
||||
function AddBlockPicker({
|
||||
candidates,
|
||||
full,
|
||||
onPick,
|
||||
}: {
|
||||
candidates: { builtin: CandidateItem[]; customWidgets: CandidateItem[] };
|
||||
full: boolean;
|
||||
onPick: (ref: SidebarBlockRef) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onEsc);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onEsc);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const hasAny = candidates.builtin.length + candidates.customWidgets.length > 0;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative mt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="field flex items-center justify-between gap-2"
|
||||
disabled={full || (!hasAny && !full)}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="truncate text-[13px]" style={{ color: full || !hasAny ? "var(--ink-3)" : "var(--ink-3)" }}>
|
||||
{full ? "已达上限" : hasAny ? "选择要添加的模块…" : "没有更多可用的块"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && !full && hasAny && (
|
||||
<div
|
||||
className="absolute z-50 mt-1 w-full overflow-y-auto rounded-xl border shadow-lg"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
borderColor: "var(--line)",
|
||||
maxHeight: "320px",
|
||||
}}
|
||||
role="listbox"
|
||||
>
|
||||
{candidates.builtin.length > 0 && (
|
||||
<AddGroup title="内置模块" items={candidates.builtin} onPick={(r) => { onPick(r); setOpen(false); }} />
|
||||
)}
|
||||
{candidates.customWidgets.length > 0 && (
|
||||
<AddGroup title="自定义工具" items={candidates.customWidgets} onPick={(r) => { onPick(r); setOpen(false); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddGroup({
|
||||
title,
|
||||
items,
|
||||
onPick,
|
||||
}: {
|
||||
title: string;
|
||||
items: CandidateItem[];
|
||||
onPick: (ref: SidebarBlockRef) => void;
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={title}>
|
||||
<p
|
||||
className="sticky top-0 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide"
|
||||
style={{ background: "var(--panel)", color: "var(--ink-3)", borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<ul className="p-1">
|
||||
{items.map((c) => {
|
||||
const Icon = c.iconKey ? ICON_MAP[c.iconKey] : undefined;
|
||||
const disabled = c.widget ? !c.widget.enabled : false;
|
||||
return (
|
||||
<li key={refKey(c.ref)}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
disabled={disabled}
|
||||
onClick={() => onPick(c.ref)}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)] focus:outline-none"
|
||||
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 w-6 h-6 rounded-md flex items-center justify-center"
|
||||
style={{
|
||||
background: c.custom ? "var(--gold-soft)" : "var(--accent-soft)",
|
||||
color: c.custom ? "var(--gold)" : "var(--accent)",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{Icon ? <Icon size={12} /> : <Code2 size={12} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
{c.label}
|
||||
</span>
|
||||
{disabled && (
|
||||
<span className="meta text-[11px]">已停用(需先在工具库启用)</span>
|
||||
)}
|
||||
</span>
|
||||
<Plus size={13} className="shrink-0" style={{ color: "var(--ink-3)" }} aria-hidden />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
frontend/app/admin/sidebar/page.tsx
Normal file
46
frontend/app/admin/sidebar/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import SidebarAdmin from "./SidebarAdmin";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import {
|
||||
fetchAdminSidebar,
|
||||
type SidebarConfig,
|
||||
type SidebarWidgetRecord,
|
||||
} from "@/lib/api";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isSuperOrOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "侧栏管理",
|
||||
};
|
||||
|
||||
export default async function AdminSidebarPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
||||
return <Forbidden text="侧栏管理仅超级管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
let config: SidebarConfig | null = null;
|
||||
let widgets: SidebarWidgetRecord[] = [];
|
||||
let initError = "";
|
||||
try {
|
||||
const res = await fetchAdminSidebar(cookie || undefined);
|
||||
config = res.config;
|
||||
widgets = res.widgets ?? [];
|
||||
} catch (e) {
|
||||
initError = e instanceof Error ? e.message : "加载失败";
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<p className="meta px-1 py-6">加载中…</p>}>
|
||||
<SidebarAdmin
|
||||
initialConfig={config}
|
||||
initialWidgets={widgets}
|
||||
initError={initError}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +99,7 @@
|
||||
栏在 scrollY=0 时的视口 top,否则第一下滚动会先空走一截再吸住 */
|
||||
--header-bar: 68px;
|
||||
--header-h: calc(var(--header-bar) + 1px);
|
||||
--main-pad-y: 2.25rem;
|
||||
--main-pad-y: 1.25rem;
|
||||
--rail-top: calc(var(--header-h) + var(--main-pad-y));
|
||||
--rail-max-h: calc(100vh - var(--rail-top) - 1rem);
|
||||
|
||||
@@ -6329,29 +6329,29 @@ a.j13-mention-token:hover {
|
||||
|
||||
/* ----- 正文时间线:日志行样式(非标题墙);日徽章为唯一强层级 ----- */
|
||||
.j13-timeline {
|
||||
position: relative;
|
||||
margin: 1rem 0 1.25rem;
|
||||
padding: 0.1rem 0 0.1rem 0.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.15rem;
|
||||
}
|
||||
/* 连续主干:贯穿所有日分组,避免按日分段导致竖线"断树" */
|
||||
.j13-timeline::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0.38rem;
|
||||
top: 0.55rem;
|
||||
bottom: 0.55rem;
|
||||
width: 1.5px;
|
||||
border-radius: 1px;
|
||||
background: color-mix(in srgb, var(--ok) 45%, var(--line));
|
||||
z-index: 0;
|
||||
}
|
||||
.j13-timeline-day {
|
||||
position: relative;
|
||||
padding-left: 1.15rem;
|
||||
}
|
||||
.j13-timeline-day::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0.28rem;
|
||||
top: 1.7rem;
|
||||
bottom: 0.2rem;
|
||||
width: 1.5px;
|
||||
border-radius: 1px;
|
||||
background: color-mix(in srgb, var(--ok) 32%, var(--line));
|
||||
}
|
||||
.j13-timeline-day:last-child::before {
|
||||
bottom: 0.85rem;
|
||||
}
|
||||
.j13-timeline-day-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -6401,12 +6401,12 @@ a.j13-mention-token:hover {
|
||||
position: absolute;
|
||||
left: -1.15rem;
|
||||
top: 0.85rem;
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
margin-left: 0.28rem;
|
||||
border-radius: 999px;
|
||||
background: var(--ok);
|
||||
border: 2px solid var(--panel);
|
||||
border: 1.5px solid var(--panel);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ok) 40%, var(--line));
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
@@ -6532,6 +6532,77 @@ a.j13-mention-token:hover {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
transition: border-color 0.15s ease, background 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
.j13-tl-edit-card:hover {
|
||||
border-color: var(--control-border-hover);
|
||||
}
|
||||
/* 已排除条目:降饱和度 + 虚线边,提示不会写入正文 */
|
||||
.j13-tl-edit-card.is-excluded {
|
||||
opacity: 0.55;
|
||||
background: var(--tint);
|
||||
border-style: dashed;
|
||||
}
|
||||
.j13-tl-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.01em;
|
||||
background: var(--ink-3);
|
||||
color: var(--panel);
|
||||
}
|
||||
.j13-tl-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.j13-tl-count.is-muted {
|
||||
background: var(--tint);
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.j13-tl-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 1.6rem 1rem;
|
||||
border: 1px dashed var(--line-2);
|
||||
border-radius: 10px;
|
||||
background: var(--tint);
|
||||
color: var(--ink-3);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.j13-tl-hint {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.j13-tl-banner {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.j13-tl-banner.is-warn,
|
||||
.j13-tl-banner.is-err {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 25%, transparent);
|
||||
}
|
||||
|
||||
/* ----- 分页 ----- */
|
||||
@@ -9018,3 +9089,69 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
|
||||
display: block;
|
||||
}
|
||||
.settings-nav-item small { margin-left:8px; }
|
||||
|
||||
/* ===== 访问统计:横向 Tab 导航 ===== */
|
||||
.admin-analytics-page {
|
||||
width: 100%;
|
||||
max-width: 1120px;
|
||||
}
|
||||
.admin-analytics-tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.15rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.admin-analytics-tab {
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
padding: 0.55rem 0.9rem;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
color: var(--ink-2);
|
||||
text-decoration: none;
|
||||
border-radius: 0.6rem 0.6rem 0 0;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.admin-analytics-tab:hover {
|
||||
background: var(--panel-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.admin-analytics-tab:visited {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.admin-analytics-tab[aria-current="page"],
|
||||
.admin-analytics-tab[aria-current="page"]:visited {
|
||||
color: var(--nav-active-ink);
|
||||
background: var(--nav-active-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.admin-analytics-tab[aria-current="page"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
.admin-analytics-tab:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* ── 侧栏管理:站长自定义工具容器 ──
|
||||
不附加面板底色/边框,视觉完全交给工具自带的 HTML/CSS;
|
||||
仅保证在侧栏列内不横向溢出。 */
|
||||
.j13-widget-body {
|
||||
min-width: 0;
|
||||
}
|
||||
.j13-widget-body > .j13-widget-html {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,13 @@ import {
|
||||
fetchPosts,
|
||||
fetchOverview,
|
||||
fetchBoardSidebar,
|
||||
fetchSidebar,
|
||||
type OverviewResponse,
|
||||
type BoardSidebarResponse,
|
||||
type SidebarPublicPayload,
|
||||
} from "@/lib/api";
|
||||
import { defaultSidebarConfig, sidebarScopeLists } from "@/lib/sidebarMeta";
|
||||
import SidebarRenderer, { type SidebarBlockProps } from "@/components/sidebar/SidebarRenderer";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { siteDocumentTitle } from "@/lib/brand";
|
||||
@@ -17,14 +21,9 @@ import Pagination from "@/components/Pagination";
|
||||
import PostRow from "@/components/PostRow";
|
||||
import FeedTabs from "@/components/home/FeedTabs";
|
||||
import FeedNewTip from "@/components/home/FeedNewTip";
|
||||
import { WorkspaceNav, WorkspaceNavMobile } from "@/components/home/WorkspaceNav";
|
||||
import { WorkspaceNavMobile } from "@/components/home/WorkspaceNav";
|
||||
import { SiteDocBreadcrumb } from "@/components/site-doc";
|
||||
import { CommunityPanels, CommunityPanelsMobile } from "@/components/home/CommunityPanels";
|
||||
import {
|
||||
BoardPanels,
|
||||
BoardPanelsMobile,
|
||||
BoardMissingPanel,
|
||||
} from "@/components/home/BoardPanels";
|
||||
import { BoardMissingPanel } from "@/components/home/BoardPanels";
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
import { defaultFeedSort, homeCanonicalQuery, normalizeFeedSort } from "@/lib/siteUrl";
|
||||
import { listingJsonLd } from "@/lib/jsonLd";
|
||||
@@ -107,13 +106,19 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
boardId > 0 && !isSearch
|
||||
? fetchBoardSidebar(boardId, cookie).catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
// 侧栏布局配置失败不阻塞页面,回落默认布局
|
||||
const sidebarPromise: Promise<SidebarPublicPayload | null> = isSearch
|
||||
? Promise.resolve(null)
|
||||
: fetchSidebar(cookie).catch(() => null);
|
||||
|
||||
const [me, postsData, overview, boardSidebar] = await Promise.all([
|
||||
const [me, postsData, overview, boardSidebar, sidebarData] = await Promise.all([
|
||||
mePromise,
|
||||
postsPromise,
|
||||
overviewPromise,
|
||||
boardSidebarPromise,
|
||||
sidebarPromise,
|
||||
]);
|
||||
const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] };
|
||||
const user = me.user;
|
||||
const boards = overview?.boards ?? [];
|
||||
const canManageAnnouncements = !!user && isAdminOrAbove(user.role);
|
||||
@@ -124,6 +129,48 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
// boardId 有效但接口失败 → 显示简卡;成功则板级右栏;否则全站右栏
|
||||
const boardPanelsMissing = showBoardPanels && !boardSidebar;
|
||||
|
||||
// 侧栏布局:板块态右栏走 board 配置(左栏跟随首页),其余走 home 配置
|
||||
const scopeLists = sidebarScopeLists(sidebar.config, showBoardPanels ? "board" : "home");
|
||||
const sidebarProps: SidebarBlockProps = {
|
||||
allowComments,
|
||||
user,
|
||||
boards,
|
||||
checkin: (boardSidebar ?? overview)?.checkin ?? undefined,
|
||||
...(overview
|
||||
? {
|
||||
stats: overview.stats,
|
||||
announcements: overview.announcements,
|
||||
canManageAnnouncements,
|
||||
ads: overview.ads ?? [],
|
||||
adsPanelTitle: overview.ads_panel_title || "自助推广",
|
||||
adsEnabled: overview.ads_enabled !== false,
|
||||
sponsors: overview.sponsors ?? [],
|
||||
sponsorsPanelTitle: overview.sponsors_panel_title || "赞助商",
|
||||
sponsorsEnabled: overview.sponsors_enabled !== false,
|
||||
hot: overview.hot,
|
||||
newUsers: overview.new_users,
|
||||
activeUsers: overview.active_users,
|
||||
}
|
||||
: {}),
|
||||
canManagePages,
|
||||
sidebarPages,
|
||||
siteDescription: publicSettings.site_description,
|
||||
...(boardSidebar
|
||||
? {
|
||||
board: boardSidebar.board,
|
||||
moderators: boardSidebar.moderators,
|
||||
boardStats: boardSidebar.stats,
|
||||
boardHot: boardSidebar.hot,
|
||||
boardActiveUsers: boardSidebar.active_users,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
// 窄屏堆叠:右栏列表 + 左栏列表(板块目录已在顶部有横向板块条,跳过)
|
||||
const mobileBlocks = [
|
||||
...scopeLists.right,
|
||||
...scopeLists.left.filter((r) => r.key !== "board_nav"),
|
||||
];
|
||||
|
||||
const pageName = isSearch
|
||||
? `搜索“${keyword}”`
|
||||
: activeBoard
|
||||
@@ -189,7 +236,12 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
<WorkspaceNavMobile boards={boards} user={user} />
|
||||
|
||||
<div className={`flex items-start gap-6 xl:gap-7 ${activeBoard ? "" : "mt-5 lg:mt-0"}`}>
|
||||
<WorkspaceNav boards={boards} user={user} />
|
||||
{/* 左栏:按配置渲染(缺省 = 板块目录) */}
|
||||
{scopeLists.left.length > 0 && (
|
||||
<aside className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]">
|
||||
<SidebarRenderer blocks={scopeLists.left} widgets={sidebar.widgets} props={sidebarProps} />
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* 中栏:tab 栏与帖子流为同一个整体卡片(左栏切板块只替换这一列) */}
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -247,85 +299,44 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右栏:选中板块时切板级面板,否则全站社区面板 */}
|
||||
{/* 右栏:选中板块时切板级面板,否则全站社区面板;均按配置渲染 */}
|
||||
{showBoardPanels ? (
|
||||
boardSidebar ? (
|
||||
<BoardPanels
|
||||
board={boardSidebar.board}
|
||||
moderators={boardSidebar.moderators}
|
||||
stats={boardSidebar.stats}
|
||||
hot={boardSidebar.hot}
|
||||
activeUsers={boardSidebar.active_users}
|
||||
checkin={boardSidebar.checkin ?? undefined}
|
||||
allowComments={allowComments}
|
||||
/>
|
||||
scopeLists.right.length > 0 ? (
|
||||
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="板块面板">
|
||||
<SidebarRenderer
|
||||
blocks={scopeLists.right}
|
||||
widgets={sidebar.widgets}
|
||||
props={sidebarProps}
|
||||
className="space-y-4"
|
||||
/>
|
||||
</aside>
|
||||
) : null
|
||||
) : boardPanelsMissing ? (
|
||||
<BoardMissingPanel />
|
||||
) : null
|
||||
) : (
|
||||
overview && (
|
||||
<CommunityPanels
|
||||
stats={overview.stats}
|
||||
hot={overview.hot}
|
||||
activeUsers={overview.active_users}
|
||||
announcements={overview.announcements}
|
||||
newUsers={overview.new_users}
|
||||
checkin={overview.checkin ?? undefined}
|
||||
allowComments={allowComments}
|
||||
canManageAnnouncements={canManageAnnouncements}
|
||||
canManagePages={canManagePages}
|
||||
sidebarPages={sidebarPages}
|
||||
siteDescription={publicSettings.site_description}
|
||||
ads={overview.ads ?? []}
|
||||
adsPanelTitle={overview.ads_panel_title || "自助推广"}
|
||||
adsEnabled={overview.ads_enabled !== false}
|
||||
sponsors={overview.sponsors ?? []}
|
||||
sponsorsPanelTitle={overview.sponsors_panel_title || "赞助商"}
|
||||
sponsorsEnabled={overview.sponsors_enabled !== false}
|
||||
) : overview && scopeLists.right.length > 0 ? (
|
||||
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="社区面板">
|
||||
<SidebarRenderer
|
||||
blocks={scopeLists.right}
|
||||
widgets={sidebar.widgets}
|
||||
props={sidebarProps}
|
||||
className="space-y-4"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 窄屏:面板下沉到帖子流之后 */}
|
||||
{showBoardPanels ? (
|
||||
boardSidebar ? (
|
||||
<div className="mt-6">
|
||||
<BoardPanelsMobile
|
||||
board={boardSidebar.board}
|
||||
moderators={boardSidebar.moderators}
|
||||
stats={boardSidebar.stats}
|
||||
hot={boardSidebar.hot}
|
||||
activeUsers={boardSidebar.active_users}
|
||||
checkin={boardSidebar.checkin ?? undefined}
|
||||
allowComments={allowComments}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
overview && (
|
||||
<div className="mt-6">
|
||||
<CommunityPanelsMobile
|
||||
stats={overview.stats}
|
||||
hot={overview.hot}
|
||||
activeUsers={overview.active_users}
|
||||
announcements={overview.announcements}
|
||||
newUsers={overview.new_users}
|
||||
checkin={overview.checkin ?? undefined}
|
||||
allowComments={allowComments}
|
||||
canManageAnnouncements={canManageAnnouncements}
|
||||
canManagePages={canManagePages}
|
||||
sidebarPages={sidebarPages}
|
||||
siteDescription={publicSettings.site_description}
|
||||
ads={overview.ads ?? []}
|
||||
adsPanelTitle={overview.ads_panel_title || "自助推广"}
|
||||
adsEnabled={overview.ads_enabled !== false}
|
||||
sponsors={overview.sponsors ?? []}
|
||||
sponsorsPanelTitle={overview.sponsors_panel_title || "赞助商"}
|
||||
sponsorsEnabled={overview.sponsors_enabled !== false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
{/* 窄屏:面板下沉到帖子流之后(右栏列表 + 左栏列表,板块目录除外) */}
|
||||
{mobileBlocks.length > 0 && (
|
||||
<div className="mt-6 lg:hidden">
|
||||
<SidebarRenderer
|
||||
blocks={mobileBlocks}
|
||||
widgets={sidebar.widgets}
|
||||
props={sidebarProps}
|
||||
className="space-y-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,10 +7,6 @@ import {
|
||||
Heart,
|
||||
MessageCircle,
|
||||
Flame,
|
||||
Layers,
|
||||
User,
|
||||
PenLine,
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -18,19 +14,20 @@ import {
|
||||
fetchComments,
|
||||
fetchPosts,
|
||||
fetchUserProfile,
|
||||
fetchSidebar,
|
||||
postDeleteTypeLabel,
|
||||
type UserProfile,
|
||||
type PostListItem,
|
||||
type Board,
|
||||
type CommentsResponse,
|
||||
type SidebarPublicPayload,
|
||||
} from "@/lib/api";
|
||||
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
||||
import { defaultSidebarConfig, sidebarScopeLists } from "@/lib/sidebarMeta";
|
||||
import SidebarRenderer, { type SidebarBlockProps } from "@/components/sidebar/SidebarRenderer";
|
||||
import { postViewerCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import CommentSection from "@/components/CommentSection";
|
||||
import PostActions from "@/components/PostActions";
|
||||
import LikeButton from "@/components/LikeButton";
|
||||
import PostToc from "@/components/PostToc";
|
||||
import { SiteDocBreadcrumb } from "@/components/site-doc";
|
||||
import MobilePostBar from "@/components/MobilePostBar";
|
||||
import Avatar from "@/components/Avatar";
|
||||
@@ -88,13 +85,6 @@ export async function generateMetadata({ params, searchParams }: PageProps): Pro
|
||||
}
|
||||
}
|
||||
|
||||
function formatYMD(dateStr: string) {
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(
|
||||
d.getDate()
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
@@ -105,162 +95,6 @@ function formatDateTime(dateStr: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
左栏模块(仅 xl 三栏时展示)
|
||||
============================================================ */
|
||||
|
||||
function BoardCard({ board }: { board: Board }) {
|
||||
const colorIdx = boardColor(board);
|
||||
return (
|
||||
<section className="j13-module shrink-0" aria-labelledby="board-card-title">
|
||||
<p className="j13-module-title mb-3" id="board-card-title">
|
||||
<Layers size={15} style={{ color: "var(--accent)" }} /> 所属板块
|
||||
</p>
|
||||
<Link href={`/?board=${board.id}`} className="flex items-center gap-3 group">
|
||||
<span
|
||||
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center shrink-0`}
|
||||
>
|
||||
<BoardIcon icon={board.icon} name={board.name} size={20} />
|
||||
</span>
|
||||
<span
|
||||
className="font-semibold text-[14.5px] truncate group-hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{board.name}
|
||||
</span>
|
||||
</Link>
|
||||
{board.description && (
|
||||
<p className="mt-2.5 text-[12.5px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||
{board.description}
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
href={`/?board=${board.id}`}
|
||||
className="btn btn-line btn-sm w-full justify-center mt-3.5"
|
||||
>
|
||||
查看该板讨论 <ArrowRight size={13} />
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthorRailCard({
|
||||
profile,
|
||||
allowComments,
|
||||
}: {
|
||||
profile: UserProfile;
|
||||
allowComments: boolean;
|
||||
}) {
|
||||
const u = profile.user;
|
||||
const signature = (u.signature || "").trim();
|
||||
const { post_count, comment_count, points, streak } = profile.stats;
|
||||
const stats = [
|
||||
{ label: "发帖", value: post_count },
|
||||
...(allowComments ? [{ label: "评论", value: comment_count }] : []),
|
||||
{ label: "积分", value: points },
|
||||
];
|
||||
return (
|
||||
<section className="j13-module shrink-0" aria-labelledby="author-card-title">
|
||||
<p className="j13-module-title mb-3" id="author-card-title">
|
||||
<User size={15} style={{ color: "var(--accent)" }} /> 作者
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/u/${u.id}`} className="shrink-0" aria-label={u.nickname}>
|
||||
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={48} />
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/u/${u.id}`}
|
||||
className="block font-semibold text-[14.5px] truncate hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{u.nickname}
|
||||
</Link>
|
||||
<p className="meta mt-0.5 truncate">@{u.username}</p>
|
||||
{u.role !== "user" && (
|
||||
<div className="mt-1">
|
||||
<RoleBadge role={u.role} boardIds={u.board_ids} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 有签名则展示;无则留空,不放占位文案 */}
|
||||
{signature ? (
|
||||
<p
|
||||
className="mt-3 text-[12.5px] leading-relaxed line-clamp-3"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
title={signature}
|
||||
>
|
||||
{signature}
|
||||
</p>
|
||||
) : null}
|
||||
<div
|
||||
className={`mt-3 grid border-t ${stats.length === 3 ? "grid-cols-3" : "grid-cols-2"}`}
|
||||
style={{ borderColor: "var(--line)" }}
|
||||
>
|
||||
{stats.map((s, i) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="px-1.5 py-2.5 text-center"
|
||||
style={i > 0 ? { borderLeft: "1px solid var(--line)" } : undefined}
|
||||
>
|
||||
<p
|
||||
className="text-[17px] font-semibold leading-none tabular-nums"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{s.value}
|
||||
</p>
|
||||
<p className="mt-1.5 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||
{s.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{streak > 0 && (
|
||||
<p
|
||||
className="mt-2 flex items-center justify-center gap-1 text-[11.5px] font-medium"
|
||||
style={{ color: "var(--gold)" }}
|
||||
>
|
||||
<Flame size={11.5} /> 连签 {streak} 天
|
||||
</p>
|
||||
)}
|
||||
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm w-full justify-center mt-3">
|
||||
访问主页
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthorPosts({ posts }: { posts: PostListItem[] }) {
|
||||
if (posts.length === 0) return null;
|
||||
return (
|
||||
<section className="j13-module flex min-h-0 flex-col" aria-labelledby="author-posts-title">
|
||||
<p className="j13-module-title mb-1 shrink-0" id="author-posts-title">
|
||||
<PenLine size={15} style={{ color: "var(--accent)" }} /> TA 的其他帖子
|
||||
</p>
|
||||
<ul className="j13-rail-scroll">
|
||||
{posts.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Link
|
||||
href={`/post/${p.id}`}
|
||||
data-tip={p.title}
|
||||
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
>
|
||||
<span
|
||||
className="block text-[13px] leading-snug line-clamp-2"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{p.title}
|
||||
</span>
|
||||
<span className="meta mt-1 block text-[11px]">{formatYMD(p.created_at)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
右栏模块(lg 起展示;移动端下沉到评论之后)
|
||||
============================================================ */
|
||||
@@ -466,11 +300,14 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
};
|
||||
const meFirst = await getMeCached(cookie || undefined);
|
||||
const canFetchComments = allowComments && (!commentsRequireLogin || !!meFirst.user);
|
||||
const [commentsData, profile, relatedData] = await Promise.all([
|
||||
const [commentsData, profile, relatedData, sidebarData] = await Promise.all([
|
||||
canFetchComments ? fetchComments(id, commentPage, cookie) : Promise.resolve(emptyComments),
|
||||
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
|
||||
fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null),
|
||||
// 侧栏布局配置失败不阻塞页面,回落默认布局
|
||||
fetchSidebar(cookie).catch(() => null),
|
||||
]);
|
||||
const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] };
|
||||
const me = meFirst;
|
||||
const comments = commentsData.comments;
|
||||
const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
|
||||
@@ -483,6 +320,26 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
const edited =
|
||||
new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000;
|
||||
|
||||
// 侧栏布局:post 范围左右两列(toc 需在下方已就绪)
|
||||
const sidebarLists = sidebarScopeLists(sidebar.config, "post");
|
||||
const hasLeftRail = sidebarLists.left.length > 0;
|
||||
const hasRightRail = sidebarLists.right.length > 0;
|
||||
const sidebarProps: SidebarBlockProps = {
|
||||
allowComments,
|
||||
postBoard: post.board,
|
||||
authorProfile: profile ?? undefined,
|
||||
authorPosts,
|
||||
toc,
|
||||
};
|
||||
// 三档网格退化:双栏 / 仅左 / 仅右 / 单栏
|
||||
const railGridClass = !hasLeftRail && !hasRightRail
|
||||
? ""
|
||||
: hasLeftRail && hasRightRail
|
||||
? "lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px]"
|
||||
: hasLeftRail
|
||||
? "lg:grid-cols-[224px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)]"
|
||||
: "lg:grid-cols-[minmax(0,1fr)_280px] xl:grid-cols-[minmax(0,1fr)_300px]";
|
||||
|
||||
// 非公开帖(待审/未通过)仅作者本人与管理团队可看,顶部给出醒目状态
|
||||
const isPublished = post.status === "published";
|
||||
const postType = normalizePostType(post.post_type);
|
||||
@@ -602,19 +459,22 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
allowComments={allowComments}
|
||||
/>
|
||||
|
||||
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏 */}
|
||||
<div className="grid grid-cols-1 items-start lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px] gap-x-6 xl:gap-x-7">
|
||||
{/* 左栏:板块 / 作者 / 作者其他帖 */}
|
||||
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏
|
||||
侧栏两列均可被站长清空,网格随之退化为两栏/单栏 */}
|
||||
<div
|
||||
className={`grid grid-cols-1 items-start gap-x-6 xl:gap-x-7 ${railGridClass}`}
|
||||
>
|
||||
{/* 左栏:按配置渲染(缺省 = 板块 / 作者 / 作者其他帖) */}
|
||||
{/* sticky 必须直接挂在 grid 子项上:aside 作为 grid item,其可粘行程
|
||||
等于行高(正文高度);内层包裹会被 items-start 缩成内容高而失去行程 */}
|
||||
<aside
|
||||
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
|
||||
aria-label="帖子导航"
|
||||
>
|
||||
<BoardCard board={post.board} />
|
||||
{profile && <AuthorRailCard profile={profile} allowComments={allowComments} />}
|
||||
{profile && <AuthorPosts posts={authorPosts} />}
|
||||
</aside>
|
||||
{hasLeftRail && (
|
||||
<aside
|
||||
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
|
||||
aria-label="帖子导航"
|
||||
>
|
||||
<SidebarRenderer blocks={sidebarLists.left} widgets={sidebar.widgets} props={sidebarProps} />
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* 中栏:正文主体 */}
|
||||
<div className="min-w-0">
|
||||
@@ -845,14 +705,16 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{/* 右栏:目录 / 同板块热议(阅读数据已并入标题下元信息条) */}
|
||||
<aside
|
||||
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
|
||||
aria-label="阅读辅助"
|
||||
>
|
||||
{toc.length > 0 && <PostToc items={toc} />}
|
||||
<RelatedPosts posts={related} boardId={post.board_id} allowComments={allowComments} />
|
||||
</aside>
|
||||
{/* 右栏:按配置渲染(缺省 = 文章目录)+ 固定的同板块热议 */}
|
||||
{hasRightRail && (
|
||||
<aside
|
||||
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
|
||||
aria-label="阅读辅助"
|
||||
>
|
||||
<SidebarRenderer blocks={sidebarLists.right} widgets={sidebar.widgets} props={sidebarProps} />
|
||||
<RelatedPosts posts={related} boardId={post.board_id} allowComments={allowComments} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 给移动端浮动操作条让位,避免遮住页尾内容 */}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Link from "next/link";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { Metadata } from "next";
|
||||
import {
|
||||
ChevronRight, FileText, MessageCircle, Flame, Calendar, Hash, Shield,
|
||||
FileText, MessageCircle, Flame, Calendar, Hash, Shield,
|
||||
Settings, KeyRound, Info, PenLine,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -178,12 +178,6 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
return (
|
||||
<div>
|
||||
<JsonLd data={profileLd} />
|
||||
<nav className="meta -mt-4 sm:-mt-6 mb-1.5 flex items-center gap-1 text-[12.5px] leading-none">
|
||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
|
||||
<ChevronRight size={13} />
|
||||
<span style={{ color: "var(--accent)" }}>{u.nickname || u.username}</span>
|
||||
</nav>
|
||||
|
||||
{/* ═══ Hero:头像 + 昵称 + 简介 + 行内统计,约 120px 高 ═══ */}
|
||||
<section
|
||||
className="panel relative mb-5 transition-[background-color,box-shadow] duration-150"
|
||||
|
||||
Reference in New Issue
Block a user