feat: 站点访问统计与可配置侧边栏组件

- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
2026-09-24 18:53:45 +08:00
parent 3e55b5d230
commit 7b327bc8cc
60 changed files with 6539 additions and 572 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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]">代码一框写完(&lt;style&gt; / &lt;script&gt; 段提交时自动拆分)</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> 返回 &quot;light&quot; | &quot;dark&quot;;<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>
);
}

View 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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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>
{/* 给移动端浮动操作条让位,避免遮住页尾内容 */}

View File

@@ -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"

View File

@@ -14,11 +14,14 @@ export default function Avatar({
size = 36,
src,
className = "",
square = false,
}: {
name: string;
size?: number;
src?: string | null;
className?: string;
/** 方形(带小圆角)轮廓,用于帖子列表行;默认圆形 */
square?: boolean;
}) {
const idx = hash(name || "?") % 8;
const initial = (name || "?").trim().charAt(0).toUpperCase();
@@ -30,7 +33,9 @@ export default function Avatar({
return (
<span
className={`inline-flex items-center justify-center shrink-0 select-none font-bold rounded-full overflow-hidden av-${idx} ${className}`}
className={`inline-flex items-center justify-center shrink-0 select-none font-bold overflow-hidden av-${idx} ${
square ? "rounded-[9px]" : "rounded-full"
} ${className}`}
style={{ width: size, height: size, fontSize: size * 0.42 }}
aria-hidden="true"
>

View File

@@ -50,7 +50,7 @@ export default function PinnedPosts({
animationDelay: `${i * 35}ms`,
}}
>
<Avatar name={p.user.nickname || p.user.username} src={p.user.avatar || undefined} size={36} />
<Avatar name={p.user.nickname || p.user.username} src={p.user.avatar || undefined} size={36} square />
<div className="flex-1 min-w-0">
<h3
className="text-[14.5px] font-bold leading-snug truncate transition-colors group-hover:text-[var(--accent)]"

View File

@@ -61,7 +61,7 @@ export default async function PostRow({
aria-label={authorName}
tabIndex={-1}
>
<Avatar name={authorName} src={post.user.avatar || undefined} size={compact ? 38 : 46} />
<Avatar name={authorName} src={post.user.avatar || undefined} size={compact ? 38 : 46} square />
</Link>
<div className="flex-1 min-w-0">

View File

@@ -165,6 +165,16 @@ export default function TimelineEditor({
onClose();
};
const keptCount = items.filter((it) => it.selected !== false).length;
const urlCount = urlsText
.split(/\n/)
.map((l) => l.trim())
.filter(Boolean).length;
const hasExistingContent = items.some(
(it) => it.title.trim() || it.body.trim()
);
const showReplaceWarning = importMode === "replace" && hasExistingContent;
return (
<>
<Modal
@@ -173,19 +183,36 @@ export default function TimelineEditor({
onClose={onClose}
zIndexClass="z-[1200]"
maxWidthClass="max-w-2xl"
panelClassName="p-0 overflow-hidden"
description="从提交记录批量导入,或手动编辑后插入正文。"
panelClassName="p-5 sm:p-6 max-h-[88vh] flex flex-col overflow-hidden"
bodyClassName="flex-1 min-h-0 flex flex-col"
footer={
<>
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
取消
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={handleInsert}>
{initialBody.trim() ? "更新时间线" : "插入时间线"}
</button>
</>
<div className="w-full flex items-center justify-between gap-2">
<span
className="text-[12.5px]"
style={{ color: keptCount > 0 ? "var(--ink-3)" : "var(--danger)" }}
>
{keptCount > 0
? `将${initialBody.trim() ? "更新" : "插入"} ${keptCount} 条`
: "没有可保留的条目"}
</span>
<div className="flex items-center gap-2">
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
取消
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleInsert}
disabled={keptCount === 0}
>
{initialBody.trim() ? "更新时间线" : "插入时间线"}
</button>
</div>
</div>
}
>
<div className="max-h-[min(70vh,640px)] overflow-y-auto px-5 py-4 space-y-4">
<div className="flex-1 min-h-0 overflow-y-auto space-y-4">
{/* 导入区 */}
<section className="j13-tl-import" aria-label="从提交记录导入">
<div className="flex items-center gap-2 mb-2">
@@ -195,16 +222,22 @@ export default function TimelineEditor({
</p>
</div>
<p className="meta text-[12px] mb-2 leading-relaxed">
粘贴 Gitea / GitHub 的 commits 列表页(一行一个)。默认自动拉取后续页;也可只贴某几页。
粘贴 Gitea / GitHub 的 commits 列表页 URL,每行一条。默认自动拉取后续页,也可只贴某几页。
</p>
<textarea
value={urlsText}
onChange={(e) => setUrlsText(e.target.value)}
onChange={(e) => {
setUrlsText(e.target.value);
setImportError(null);
}}
rows={3}
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.example.com/user/repo/commits/branch/main"}
className="j13-tl-urls"
disabled={importing}
/>
{urlCount > 0 ? (
<p className="j13-tl-hint">已识别 {urlCount} 条 URL</p>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-3">
<label className="inline-flex items-center gap-1.5 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<input
@@ -242,18 +275,24 @@ export default function TimelineEditor({
)}
</button>
</div>
{importError ? (
<p className="mt-2 text-[12.5px] leading-relaxed" style={{ color: "var(--danger)" }}>
{importError}
{showReplaceWarning ? (
<p className="j13-tl-banner is-warn">
替换模式会丢弃当前已编辑的 {items.length} 条内容;如需保留请改选「追加」。
</p>
) : null}
{importError ? (
<p className="j13-tl-banner is-err">{importError}</p>
) : null}
</section>
{/* 条目列表 */}
<div className="flex items-center justify-between gap-2">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
时间线条目
</p>
<div className="flex items-center gap-2">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
时间线条目
</p>
<span className="j13-tl-count is-muted">{items.length} 项</span>
</div>
<div className="flex items-center gap-1.5">
<button type="button" className="btn btn-line btn-sm" onClick={sortByDate}>
按日期整理
@@ -268,80 +307,99 @@ export default function TimelineEditor({
</div>
</div>
<ul className="space-y-3">
{items.map((it, idx) => (
<li key={idx} className="j13-tl-edit-card">
<div className="flex items-start gap-2">
<label className="mt-2 shrink-0">
<input
type="checkbox"
checked={it.selected !== false}
onChange={(e) => updateItem(idx, { selected: e.target.checked })}
aria-label="保留此条"
/>
</label>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap gap-2">
{items.length === 0 ? (
<div className="j13-tl-empty">
<p>还没有时间线条目</p>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setItems([emptyItem()])}
>
<Plus size={14} /> 添加一条
</button>
</div>
) : (
<ul className="space-y-3">
{items.map((it, idx) => (
<li
key={idx}
className={`j13-tl-edit-card${it.selected === false ? " is-excluded" : ""}`}
>
<div className="flex items-start gap-2">
<label className="mt-2 shrink-0">
<input
type="date"
value={it.date}
onChange={(e) => updateItem(idx, { date: e.target.value })}
className="j13-tl-field w-[9.5rem]"
type="checkbox"
checked={it.selected !== false}
onChange={(e) => updateItem(idx, { selected: e.target.checked })}
aria-label="保留此条"
/>
<input
type="text"
value={it.title}
maxLength={200}
placeholder="标题"
onChange={(e) => updateItem(idx, { title: e.target.value })}
className="j13-tl-field flex-1 min-w-[10rem]"
</label>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
{it.selected === false ? (
<span className="j13-tl-tag">已排除</span>
) : null}
<input
type="date"
value={it.date}
onChange={(e) => updateItem(idx, { date: e.target.value })}
className="j13-tl-field w-[9.5rem]"
/>
<input
type="text"
value={it.title}
maxLength={200}
placeholder="标题"
onChange={(e) => updateItem(idx, { title: e.target.value })}
className="j13-tl-field flex-1 min-w-[10rem]"
/>
</div>
<textarea
value={it.body}
rows={2}
placeholder="说明(可选,支持 Markdown)"
onChange={(e) => updateItem(idx, { body: e.target.value })}
className="j13-tl-field w-full"
/>
{it.sourceUrl ? (
<p className="meta text-[11.5px] truncate" title={it.sourceUrl}>
来自提交 · {it.sourceUrl}
</p>
) : null}
</div>
<div className="flex flex-col gap-0.5 shrink-0">
<button
type="button"
className="j13-md-tool"
aria-label="上移"
disabled={idx === 0}
onClick={() => moveItem(idx, -1)}
>
<ArrowUp size={14} />
</button>
<button
type="button"
className="j13-md-tool"
aria-label="下移"
disabled={idx === items.length - 1}
onClick={() => moveItem(idx, 1)}
>
<ArrowDown size={14} />
</button>
<button
type="button"
className="j13-md-tool"
aria-label="删除"
onClick={() => setDeleteIdx(idx)}
>
<Trash2 size={14} />
</button>
</div>
<textarea
value={it.body}
rows={2}
placeholder="说明(可选,支持 Markdown)"
onChange={(e) => updateItem(idx, { body: e.target.value })}
className="j13-tl-field w-full"
/>
{it.sourceUrl ? (
<p className="meta text-[11.5px] truncate" title={it.sourceUrl}>
来自提交 · {it.sourceUrl}
</p>
) : null}
</div>
<div className="flex flex-col gap-0.5 shrink-0">
<button
type="button"
className="j13-md-tool"
aria-label="上移"
disabled={idx === 0}
onClick={() => moveItem(idx, -1)}
>
<ArrowUp size={14} />
</button>
<button
type="button"
className="j13-md-tool"
aria-label="下移"
disabled={idx === items.length - 1}
onClick={() => moveItem(idx, 1)}
>
<ArrowDown size={14} />
</button>
<button
type="button"
className="j13-md-tool"
aria-label="删除"
onClick={() => setDeleteIdx(idx)}
>
<Trash2 size={14} />
</button>
</div>
</div>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
</Modal>

View File

@@ -16,7 +16,10 @@ export default function VisitBeacon() {
const now = Date.now();
if (lastRef.current.path === pathname && now - lastRef.current.at < 30_000) return;
lastRef.current = { path: pathname, at: now };
void apiTelemetryPageView();
// 带 query 完整路径 + 来源页,供后台来源分析
const full = pathname + (typeof window !== "undefined" ? window.location.search : "");
const ref = typeof document !== "undefined" ? document.referrer : "";
void apiTelemetryPageView(full, ref);
}, [pathname]);
return null;

View File

@@ -24,6 +24,8 @@ import {
FileText,
CircleDollarSign,
Columns3,
PanelLeft,
BarChart3,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
@@ -64,6 +66,7 @@ const NAV_GROUPS: NavGroup[] = [
title: "概览",
items: [
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
{ href: "/admin/analytics", label: "访问统计", icon: BarChart3, visible: (u) => isSuperOrOwner(u.role) },
],
},
{
@@ -145,6 +148,12 @@ const NAV_GROUPS: NavGroup[] = [
icon: Palette,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/sidebar",
label: "侧栏管理",
icon: PanelLeft,
visible: (u) => isSuperOrOwner(u.role),
},
],
},
];

View File

@@ -25,7 +25,7 @@ import { formatCompact } from "@/lib/format";
/* ---------- 1. 板块管理员 ---------- */
function Moderators({ items }: { items: BoardModerator[] }) {
export function Moderators({ items }: { items: BoardModerator[] }) {
return (
<section className="j13-module" aria-label="板块管理员">
<p className="j13-module-title mb-2">
@@ -56,7 +56,7 @@ function Moderators({ items }: { items: BoardModerator[] }) {
/* ---------- 2. 板块概况 ---------- */
function BoardIntro({ board }: { board: BoardWithCount }) {
export function BoardIntro({ board }: { board: BoardWithCount }) {
return (
<section className="j13-module" aria-label="板块概况">
<p className="j13-module-title mb-2">
@@ -82,7 +82,7 @@ function BoardIntro({ board }: { board: BoardWithCount }) {
/* ---------- 3. 板块数据 + 签到 ---------- */
function BoardPulse({
export function BoardPulse({
stats,
checkin,
allowComments,
@@ -156,7 +156,7 @@ function BoardPulse({
/* ---------- 4. 本板热门 ---------- */
function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
export function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
return (
<section className="j13-module" aria-label="本板热门">
<p className="j13-module-title mb-1.5">
@@ -196,7 +196,7 @@ function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowCommen
/* ---------- 5. 本板活跃 ---------- */
function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
export function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
return (
<section className="j13-module" aria-label="本板活跃成员">
<p className="j13-module-title mb-2">

View File

@@ -38,7 +38,7 @@ import { AD_IMAGE_HEIGHT, AD_IMAGE_SIZE_LABEL, AD_IMAGE_WIDTH } from "@/lib/ads"
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
function PulseCard({
export function PulseCard({
stats,
checkin,
allowComments,
@@ -130,7 +130,7 @@ function PulseCard({
/* ---------- 关于本站(侧栏页目录,置于右栏最底) ---------- */
function AboutSite({
export function AboutSite({
pages,
siteDescription,
canManagePages,
@@ -175,7 +175,7 @@ function AboutSite({
/* ---------- 站点公告 ---------- */
function Announcements({
export function Announcements({
items,
canManageAnnouncements,
}: {
@@ -248,7 +248,7 @@ function Announcements({
/* ---------- 热门榜 ---------- */
function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
export function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
return (
<section className="j13-module" aria-label="热门讨论">
<p className="j13-module-title mb-1.5">
@@ -288,7 +288,7 @@ function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComm
/* ---------- 最新注册 ---------- */
function NewUsers({ users }: { users: NewUser[] }) {
export function NewUsers({ users }: { users: NewUser[] }) {
if (users.length === 0) return null;
return (
<section className="j13-module" aria-label="最新注册">
@@ -323,7 +323,7 @@ function NewUsers({ users }: { users: NewUser[] }) {
/* ---------- 活跃成员(本周发帖 / 回复) ---------- */
function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
export function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
return (
<section className="j13-module" aria-label="本周活跃成员">
<p className="j13-module-title mb-2">
@@ -383,7 +383,7 @@ function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComme
/* ---------- 友情链接 ---------- */
function FriendLinks() {
export function FriendLinks() {
if (FRIEND_LINKS.length === 0) return null;
return (
<section className="j13-module" aria-label="友情链接">
@@ -441,7 +441,7 @@ type PanelsProps = {
sponsorsEnabled?: boolean;
};
function AdsPanel({
export function AdsPanel({
items,
title,
enabled,
@@ -529,7 +529,7 @@ function AdsPanel({
);
}
function SponsorsPanel({
export function SponsorsPanel({
items,
title,
enabled,

View File

@@ -12,81 +12,95 @@ function boardHref(id: number) {
return `/?board=${id}`;
}
// 桌面左栏:整块模块,与右栏 j13-module 对齐
export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?: User | null }) {
// 板块目录裸面板:只有 j13-module 本体,不含外层 nav / 宽度 / sticky 类,
// 供侧栏注册表按管理端配置在任意侧栏渲染。
export function WorkspaceNavPanel({
boards,
user,
}: {
boards: BoardWithCount[];
user?: User | null;
}) {
const params = useSearchParams();
const activeBoard = Number(params.get("board")) || 0;
const showAdmin = !!user && isStaff(user.role);
return (
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden" aria-label="板块目录">
<p className="j13-module-title shrink-0">
<LayoutGrid size={15} style={{ color: "var(--accent)" }} aria-hidden />
板块目录
{boards.length > 0 ? (
<span className="ml-auto meta text-[11.5px] font-medium tracking-normal tabular-nums">
{boards.length} 个
</span>
) : null}
</p>
<div className="j13-rail-scroll j13-board-list mt-2.5 min-h-0 flex-1">
<Link
href="/"
className="j13-bcard"
aria-current={activeBoard === 0 ? "page" : undefined}
>
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<Home size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
</Link>
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
{boards.map((b) => (
<Link
key={b.id}
href={boardHref(b.id)}
className="j13-bcard"
aria-current={activeBoard === b.id ? "page" : undefined}
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
>
<span className={`av-${boardColor(b)} j13-bcard-ico`} aria-hidden="true">
<BoardIcon icon={b.icon} name={b.name} size={15} />
</span>
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
<span className="j13-bcard-name truncate">{b.name}</span>
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
</span>
</Link>
))}
</div>
{showAdmin ? (
<div className="shrink-0 mt-1.5 pt-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<Link href="/admin" className="j13-bcard">
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<ShieldCheck size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">管理面板</span>
</Link>
</div>
) : null}
</section>
);
}
// 桌面左栏:整块模块,与右栏 j13-module 对齐
export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?: User | null }) {
return (
// sticky 必须挂在 flex 子项上:父级 items-start 会把内层缩成内容高,失去粘滞行程
<nav
className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]"
aria-label="工作台导航"
>
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden">
<p className="j13-module-title shrink-0">
<LayoutGrid size={15} style={{ color: "var(--accent)" }} aria-hidden />
板块目录
{boards.length > 0 ? (
<span className="ml-auto meta text-[11.5px] font-medium tracking-normal tabular-nums">
{boards.length} 个
</span>
) : null}
</p>
<div className="j13-rail-scroll j13-board-list mt-2.5 min-h-0 flex-1">
<Link
href="/"
className="j13-bcard"
aria-current={activeBoard === 0 ? "page" : undefined}
>
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<Home size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
</Link>
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
{boards.map((b) => (
<Link
key={b.id}
href={boardHref(b.id)}
className="j13-bcard"
aria-current={activeBoard === b.id ? "page" : undefined}
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
>
<span className={`av-${boardColor(b)} j13-bcard-ico`} aria-hidden="true">
<BoardIcon icon={b.icon} name={b.name} size={15} />
</span>
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
<span className="j13-bcard-name truncate">{b.name}</span>
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
</span>
</Link>
))}
</div>
{showAdmin ? (
<div className="shrink-0 mt-1.5 pt-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<Link href="/admin" className="j13-bcard">
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<ShieldCheck size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">管理面板</span>
</Link>
</div>
) : null}
</section>
<WorkspaceNavPanel boards={boards} user={user} />
</nav>
);
}

View File

@@ -0,0 +1,170 @@
import Link from "next/link";
import { Layers, ArrowRight, User, Flame, PenLine } from "lucide-react";
import type { UserProfile, PostListItem, Board } from "@/lib/api";
import BoardIcon, { boardColor } from "@/components/BoardIcon";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
// formatYMD 帖子详情页左栏「作者其他帖」日期
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")}`;
}
/* ============================================================
帖子详情页侧栏块(自 page.tsx 原样迁出,供侧栏注册表按配置渲染)
============================================================ */
export 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>
);
}
export 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>
);
}
export 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>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { useEffect, useRef } from "react";
import type { SidebarWidgetPublic, User } from "@/lib/api";
import { scopeWidgetCss } from "@/lib/widgetCode";
import { ensureWidgetApi } from "@/lib/widgetApi";
// 站长自定义工具块:HTML/CSS 随 SSR 直出(dangerouslySetInnerHTML),
// JS 在客户端挂载时注入执行、卸载时移除。站长可信内容,不做消毒/沙箱。
// CSS 经 scopeWidgetCss 限定在 `.j13-widget-{id}` 容器内,不污染页面其他区域;
// 工具内可直接用站点主题变量(var(--accent) 等)与 window.J13 接口。
export default function CustomWidgetBlock({
widget,
viewer,
}: {
widget: SidebarWidgetPublic;
viewer?: User | null;
}) {
const hostRef = useRef<HTMLDivElement>(null);
// 先于站长 JS 建立站点接口(声明顺序保证本 effect 先执行)
useEffect(() => {
ensureWidgetApi(viewer);
}, [viewer]);
useEffect(() => {
const host = hostRef.current;
if (!host || !widget.js) return;
const script = document.createElement("script");
script.dataset.widgetScript = String(widget.id);
script.textContent = widget.js;
host.appendChild(script);
return () => {
script.remove();
};
}, [widget.id, widget.js]);
return (
<section
className={`j13-widget-body j13-widget-${widget.id}`}
data-widget-id={widget.id}
data-widget-name={widget.name}
>
{widget.css ? (
<style dangerouslySetInnerHTML={{ __html: scopeWidgetCss(widget.css, widget.id) }} />
) : null}
{widget.html ? (
<div className="j13-widget-html" dangerouslySetInnerHTML={{ __html: widget.html }} />
) : null}
{/* 自定义 JS 的宿主节点:脚本由客户端创建后挂到此处执行 */}
<div ref={hostRef} />
</section>
);
}

View File

@@ -0,0 +1,40 @@
import { Fragment } from "react";
import type { SidebarBlockRef, SidebarWidgetPublic } from "@/lib/sidebarMeta";
import { renderSidebarBlock, type SidebarBlockProps } from "./registry";
// 页面从本文件导入该类型(registry 中定义)
export type { SidebarBlockProps };
import CustomWidgetBlock from "./CustomWidgetBlock";
// 按管理端配置渲染一列侧栏块:
// - 内置块经注册表解析,未知 key / 数据缺失静默跳过;
// - 自定义工具引用已删除或停用(公开载荷不含)时静默跳过;
// - className 缺省时输出 Fragment,布局容器(gap/space-y、sticky、限高)由页面自行决定。
export default function SidebarRenderer({
blocks,
widgets,
props,
className,
}: {
blocks: SidebarBlockRef[];
widgets: SidebarWidgetPublic[];
props: SidebarBlockProps;
className?: string;
}) {
const items = blocks
.map((ref, i) => {
if (ref.widget) {
const widget = widgets.find((x) => x.id === ref.widget);
if (!widget) return null;
return <CustomWidgetBlock key={`w-${widget.id}-${i}`} widget={widget} viewer={props.user} />;
}
if (!ref.key) return null;
const node = renderSidebarBlock(ref.key, props);
if (node === null) return null;
return <Fragment key={`k-${ref.key}-${i}`}>{node}</Fragment>;
})
.filter((x) => x !== null);
if (items.length === 0) return null;
return className ? <div className={className}>{items}</div> : <>{items}</>;
}

View File

@@ -0,0 +1,155 @@
import type {
ActiveUser,
AnnouncementSummary,
Board,
BoardModerator,
BoardSidebarStats,
BoardWithCount,
CheckinStatus,
NewUser,
OverviewStats,
PostListItem,
PublicAdItem,
PublicSponsorItem,
SidebarPageItem,
User,
UserProfile,
} from "@/lib/api";
import PostToc, { type TocItem } from "@/components/PostToc";
import { WorkspaceNavPanel } from "@/components/home/WorkspaceNav";
import {
PulseCard,
Announcements,
AdsPanel,
SponsorsPanel,
HotRanking,
NewUsers,
ActiveUsers,
FriendLinks,
AboutSite,
} from "@/components/home/CommunityPanels";
import {
Moderators,
BoardIntro,
BoardPulse,
BoardHot,
BoardActive,
} from "@/components/home/BoardPanels";
import {
BoardCard as PostBoardCard,
AuthorRailCard as PostAuthorCard,
AuthorPosts as PostAuthorPosts,
} from "@/components/post/PostRailBlocks";
// 侧栏块 props 汇总:页面把已有数据整包传入,块按需取用;
// 某块所需数据缺失(如板块态没传 stats)时对应块渲染为空,安全跳过。
export interface SidebarBlockProps {
allowComments: boolean;
user?: User | null;
checkin?: CheckinStatus | null;
// home 范围
boards?: BoardWithCount[];
stats?: OverviewStats;
announcements?: AnnouncementSummary[];
canManageAnnouncements?: boolean;
ads?: PublicAdItem[];
adsPanelTitle?: string;
adsEnabled?: boolean;
sponsors?: PublicSponsorItem[];
sponsorsPanelTitle?: string;
sponsorsEnabled?: boolean;
hot?: PostListItem[];
newUsers?: NewUser[];
activeUsers?: ActiveUser[];
sidebarPages?: SidebarPageItem[];
siteDescription?: string;
canManagePages?: boolean;
// board 范围
board?: BoardWithCount;
moderators?: BoardModerator[];
boardStats?: BoardSidebarStats;
boardHot?: PostListItem[];
boardActiveUsers?: ActiveUser[];
// post 范围
postBoard?: Board;
authorProfile?: UserProfile;
authorPosts?: PostListItem[];
toc?: TocItem[];
}
// 内置块注册表:key → 组件。数据未就绪或未知 key 返回 null(渲染层静默跳过)。
export function renderSidebarBlock(key: string, p: SidebarBlockProps) {
switch (key) {
case "board_nav":
return p.boards ? <WorkspaceNavPanel boards={p.boards} user={p.user} /> : null;
case "pulse":
return p.stats ? (
<PulseCard stats={p.stats} checkin={p.checkin} allowComments={p.allowComments} />
) : null;
case "announcements":
return p.announcements ? (
<Announcements items={p.announcements} canManageAnnouncements={p.canManageAnnouncements} />
) : null;
case "ads":
return p.ads && p.adsEnabled !== false ? (
<AdsPanel items={p.ads} title={p.adsPanelTitle || "自助推广"} enabled />
) : null;
case "sponsors":
return p.sponsors && p.sponsorsEnabled !== false ? (
<SponsorsPanel
items={p.sponsors}
title={p.sponsorsPanelTitle || "赞助商"}
enabled
/>
) : null;
case "hot":
return p.hot ? <HotRanking posts={p.hot} allowComments={p.allowComments} /> : null;
case "new_users":
return p.newUsers ? <NewUsers users={p.newUsers} /> : null;
case "active_users":
return p.activeUsers ? (
<ActiveUsers users={p.activeUsers} allowComments={p.allowComments} />
) : null;
case "friend_links":
return <FriendLinks />;
case "about_site":
return p.sidebarPages || p.canManagePages ? (
<AboutSite
pages={p.sidebarPages ?? []}
siteDescription={p.siteDescription}
canManagePages={p.canManagePages}
/>
) : null;
case "board_moderators":
return p.moderators ? <Moderators items={p.moderators} /> : null;
case "board_intro":
return p.board ? <BoardIntro board={p.board} /> : null;
case "board_pulse":
return p.boardStats ? (
<BoardPulse stats={p.boardStats} checkin={p.checkin} allowComments={p.allowComments} />
) : null;
case "board_hot":
return p.boardHot ? (
<BoardHot posts={p.boardHot} allowComments={p.allowComments} />
) : null;
case "board_active":
return p.boardActiveUsers ? (
<BoardActive users={p.boardActiveUsers} allowComments={p.allowComments} />
) : null;
case "post_board":
return p.postBoard ? <PostBoardCard board={p.postBoard} /> : null;
case "post_author":
return p.authorProfile ? (
<PostAuthorCard profile={p.authorProfile} allowComments={p.allowComments} />
) : null;
case "post_author_posts":
return p.authorPosts ? <PostAuthorPosts posts={p.authorPosts} /> : null;
case "post_toc":
return p.toc && p.toc.length > 0 ? <PostToc items={p.toc} /> : null;
default:
return null;
}
}

View File

@@ -1269,17 +1269,257 @@ export async function apiAdminAnalytics(range: "7d" | "30d" = "7d"): Promise<Ana
return res.json();
}
export async function apiTelemetryPageView(): Promise<void> {
export async function apiTelemetryPageView(path: string, ref: string): Promise<void> {
try {
await fetchWithRefresh("/api/telemetry/pageview", {
method: "POST",
headers: clientHeaders(),
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ path, ref }),
});
} catch {
/* 埋点失败不影响浏览 */
}
}
// ===== 访问统计(管理端,仅超管/站长) =====
export interface VisitDayPoint {
date: string;
pv: number;
uv: number;
}
export interface VisitNamedCount {
key: string;
label: string;
count: number;
}
export interface VisitPageRow {
path: string;
pv: number;
uv: number;
share: number;
}
export interface VisitOverview {
range_days: number;
trend: VisitDayPoint[];
today_pv: number;
today_uv: number;
yesterday_pv: number;
yesterday_uv: number;
logged_in_pv: number;
devices: VisitNamedCount[];
sources: VisitNamedCount[];
top_pages: VisitPageRow[];
}
export interface VisitMinutePoint {
minute: string;
pv: number;
}
export interface VisitEventItem {
id: number;
created_at: string;
kind: string;
path: string;
ref_host: string;
ref_kind: string;
user_id: number;
username: string;
ip: string;
device: string;
os: string;
browser: string;
bot: string;
status: number;
}
export interface VisitRealtime {
minutes: VisitMinutePoint[];
pv_5min: number;
pv_30min: number;
online: number;
top_pages: VisitNamedCount[];
recent: VisitEventItem[];
}
export interface VisitSourcesKindDay {
date: string;
items: VisitNamedCount[];
}
export interface VisitSources {
range_days: number;
kinds: VisitNamedCount[];
search_hosts: VisitNamedCount[];
external: VisitNamedCount[];
trend: VisitSourcesKindDay[];
}
export interface VisitPages {
range_days: number;
total: number;
items: VisitPageRow[];
}
export interface VisitVisitors {
range_days: number;
devices: VisitNamedCount[];
oses: VisitNamedCount[];
browsers: VisitNamedCount[];
logged_in_pv: number;
guest_pv: number;
new_visitors: number;
returning: number;
hourly: VisitNamedCount[];
}
export interface VisitSecurity {
range_days: number;
bots: VisitNamedCount[];
bot_trend: VisitDayPoint[];
probe_status: VisitNamedCount[];
probe_ips: VisitNamedCount[];
login_failed: number;
failed_users: number;
}
export interface VisitEventsQuery {
kind?: string;
bot?: string;
path?: string;
ip?: string;
start?: string;
end?: string;
page?: number;
size?: number;
}
export interface VisitEventsResult {
total: number;
items: VisitEventItem[];
}
export interface VisitUsage {
rows: number;
bytes: number;
}
export interface VisitSettingsPayload {
enabled: boolean;
retention_days: number;
usage: VisitUsage;
dropped: number;
}
// SSR 首拉:透传 cookie
async function fetchVisitStats<T>(path: string, cookieHeader?: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, ssrInit(cookieHeaders(cookieHeader)));
if (!res.ok) throw new Error("failed");
return res.json();
}
// 客户端拉取:带 CSRF + 自动续期
async function apiVisitStats<T>(path: string, init?: RequestInit): Promise<T> {
const headers = init?.body
? clientHeaders({ "Content-Type": "application/json" })
: clientHeaders();
const res = await fetchWithRefresh(path, {
method: init?.method ?? "GET",
...init,
headers,
});
if (!res.ok) throw new Error("failed");
return res.json();
}
export function fetchStatsOverview(cookieHeader?: string, range = "7d") {
return fetchVisitStats<VisitOverview>(`/api/admin/stats/overview?range=${range}`, cookieHeader);
}
export function apiStatsOverview(range = "7d") {
return apiVisitStats<VisitOverview>(`/api/admin/stats/overview?range=${range}`);
}
export function fetchStatsRealtime(cookieHeader?: string) {
return fetchVisitStats<VisitRealtime>("/api/admin/stats/realtime", cookieHeader);
}
export function apiStatsRealtime() {
return apiVisitStats<VisitRealtime>("/api/admin/stats/realtime");
}
export function fetchStatsSources(cookieHeader?: string, range = "7d") {
return fetchVisitStats<VisitSources>(`/api/admin/stats/sources?range=${range}`, cookieHeader);
}
export function apiStatsSources(range = "7d") {
return apiVisitStats<VisitSources>(`/api/admin/stats/sources?range=${range}`);
}
export function fetchStatsPages(cookieHeader?: string, range = "7d", q = "", page = 1, size = 20) {
return fetchVisitStats<VisitPages>(
`/api/admin/stats/pages?range=${range}&q=${encodeURIComponent(q)}&page=${page}&size=${size}`,
cookieHeader
);
}
export function apiStatsPages(range = "7d", q = "", page = 1, size = 20) {
return apiVisitStats<VisitPages>(
`/api/admin/stats/pages?range=${range}&q=${encodeURIComponent(q)}&page=${page}&size=${size}`
);
}
export function fetchStatsVisitors(cookieHeader?: string, range = "7d") {
return fetchVisitStats<VisitVisitors>(`/api/admin/stats/visitors?range=${range}`, cookieHeader);
}
export function apiStatsVisitors(range = "7d") {
return apiVisitStats<VisitVisitors>(`/api/admin/stats/visitors?range=${range}`);
}
export function fetchStatsSecurity(cookieHeader?: string, range = "7d") {
return fetchVisitStats<VisitSecurity>(`/api/admin/stats/security?range=${range}`, cookieHeader);
}
export function apiStatsSecurity(range = "7d") {
return apiVisitStats<VisitSecurity>(`/api/admin/stats/security?range=${range}`);
}
export function fetchStatsEvents(cookieHeader?: string, query: VisitEventsQuery = {}) {
const p = new URLSearchParams();
if (query.kind) p.set("kind", query.kind);
if (query.bot) p.set("bot", query.bot);
if (query.path) p.set("path", query.path);
if (query.ip) p.set("ip", query.ip);
if (query.start) p.set("start", query.start);
if (query.end) p.set("end", query.end);
p.set("page", String(query.page ?? 1));
p.set("size", String(query.size ?? 50));
return fetchVisitStats<VisitEventsResult>(`/api/admin/stats/events?${p.toString()}`, cookieHeader);
}
export function apiStatsEvents(query: VisitEventsQuery = {}) {
const p = new URLSearchParams();
if (query.kind) p.set("kind", query.kind);
if (query.bot) p.set("bot", query.bot);
if (query.path) p.set("path", query.path);
if (query.ip) p.set("ip", query.ip);
if (query.start) p.set("start", query.start);
if (query.end) p.set("end", query.end);
p.set("page", String(query.page ?? 1));
p.set("size", String(query.size ?? 50));
return apiVisitStats<VisitEventsResult>(`/api/admin/stats/events?${p.toString()}`);
}
export function fetchStatsSettings(cookieHeader?: string) {
return fetchVisitStats<VisitSettingsPayload>("/api/admin/stats/settings", cookieHeader);
}
export function apiStatsSettings() {
return apiVisitStats<VisitSettingsPayload>("/api/admin/stats/settings");
}
export function apiStatsSaveSettings(enabled: boolean, retentionDays: number) {
return apiVisitStats<VisitSettingsPayload>("/api/admin/stats/settings", {
method: "PUT",
body: JSON.stringify({ enabled, retention_days: retentionDays }),
});
}
export function apiStatsPurge(days: number) {
return apiVisitStats<{ ok: boolean }>("/api/admin/stats/purge", {
method: "POST",
body: JSON.stringify({ days }),
});
}
export type AdminChatRoomType = "group" | "direct";
export interface AdminChatParticipant {
@@ -3451,3 +3691,68 @@ export async function fetchAdminSponsorsConfig(cookie?: string): Promise<Sponsor
if (!res.ok) throw new Error("加载赞助商配置失败");
return res.json();
}
// ===== 侧栏管理(布局配置 + 自定义 HTML 工具) =====
import type {
SidebarConfig,
SidebarPublicPayload,
SidebarWidgetRecord,
} from "@/lib/sidebarMeta";
export type {
SidebarConfig,
SidebarScope,
SidebarScopeConfig,
SidebarBlockRef,
SidebarPublicPayload,
SidebarWidgetPublic,
SidebarWidgetRecord,
} from "@/lib/sidebarMeta";
/** SSR 公开侧栏载荷;失败由调用方回落默认布局 */
export async function fetchSidebar(cookieHeader?: string): Promise<SidebarPublicPayload> {
const res = await fetch(`${API_BASE}/api/sidebar`, ssrInit(cookieHeaders(cookieHeader)));
if (!res.ok) throw new Error("获取侧栏配置失败");
return res.json();
}
/** SSR 管理端侧栏配置(config + 全量工具含停用);失败由调用方提示 */
export async function fetchAdminSidebar(
cookie?: string
): Promise<{ config: SidebarConfig; widgets: SidebarWidgetRecord[] }> {
const res = await fetch(`${API_BASE}/api/admin/sidebar`, {
...ssrInit(cookie ? { Cookie: cookie } : undefined),
credentials: "include",
});
if (!res.ok) throw new Error("加载侧栏配置失败");
return res.json();
}
export async function apiAdminGetSidebar(): Promise<{
config: SidebarConfig;
widgets: SidebarWidgetRecord[];
}> {
return apiOperations("/api/admin/sidebar");
}
export async function apiAdminSaveSidebarConfig(config: SidebarConfig): Promise<{ config: SidebarConfig }> {
return apiOperations("/api/admin/sidebar/config", "PUT", { config });
}
export async function apiAdminCreateSidebarWidget(
body: Pick<SidebarWidgetRecord, "name" | "html" | "css" | "js">
): Promise<{ widget: SidebarWidgetRecord }> {
return apiOperations("/api/admin/sidebar/widgets", "POST", body);
}
export async function apiAdminUpdateSidebarWidget(
id: number,
body: Pick<SidebarWidgetRecord, "name" | "html" | "css" | "js" | "enabled">
): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/sidebar/widgets/${id}`, "PUT", body);
}
export async function apiAdminDeleteSidebarWidget(id: number): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/sidebar/widgets/${id}`, "DELETE");
}

179
frontend/lib/sidebarMeta.ts Normal file
View File

@@ -0,0 +1,179 @@
// 侧栏管理:内置块元数据与默认布局(管理页与前台注册表共用,勿在此引入组件)
export type SidebarScope = "home" | "board" | "post";
export interface SidebarBlockRef {
key?: string;
widget?: number;
}
export interface SidebarScopeConfig {
left?: SidebarBlockRef[] | null;
right?: SidebarBlockRef[] | null;
}
export interface SidebarConfig {
version: number;
scopes: Partial<Record<SidebarScope, SidebarScopeConfig>>;
}
export interface SidebarWidgetPublic {
id: number;
name: string;
html: string;
css: string;
js: string;
}
export interface SidebarPublicPayload {
config: SidebarConfig;
widgets: SidebarWidgetPublic[];
}
export interface SidebarWidgetRecord {
id: number;
name: string;
html: string;
css: string;
js: string;
enabled: boolean;
created_at: string;
updated_at: string;
}
// 每侧最多放置块数(与后端 SidebarMaxBlocksPerSide 一致)
export const SIDEBAR_MAX_BLOCKS_PER_SIDE = 24;
export const SIDEBAR_SCOPE_LABELS: Record<SidebarScope, string> = {
home: "首页",
board: "板块页",
post: "帖子详情",
};
export const SIDEBAR_SIDE_LABELS: Record<"left" | "right", string> = {
left: "左栏",
right: "右栏",
};
// 各页面范围可用的内置块(key → 展示名),与后端 sidebarBuiltinKeys 保持一致
export const SIDEBAR_WIDGET_META: Record<SidebarScope, Record<string, string>> = {
home: {
board_nav: "板块目录",
pulse: "站点数据",
announcements: "站点公告",
ads: "自助推广",
sponsors: "赞助商",
hot: "热门讨论",
new_users: "最新注册",
active_users: "活跃成员",
friend_links: "友情链接",
about_site: "关于本站",
},
board: {
board_moderators: "板块管理员",
board_intro: "板块概况",
board_pulse: "板块数据",
board_hot: "本板热门",
board_active: "活跃成员",
},
post: {
post_board: "所属板块",
post_author: "作者名片",
post_author_posts: "作者其他帖",
post_toc: "文章目录",
},
};
// 内置块 icon(字符串引用,渲染方 import lucide-react 时映射)
// 保持轻量:不在 sidebarMeta 里引入组件
export const SIDEBAR_BLOCK_ICON: Record<string, string> = {
board_nav: "layout-grid",
pulse: "bar-chart-3",
announcements: "megaphone",
ads: "circle-dollar-sign",
sponsors: "handshake",
hot: "flame",
new_users: "user-plus",
active_users: "users",
friend_links: "link2",
about_site: "info",
board_moderators: "shield-check",
board_intro: "file-text",
board_pulse: "bar-chart-3",
board_hot: "flame",
board_active: "users",
post_board: "layers",
post_author: "user",
post_author_posts: "pen-line",
post_toc: "list",
};
// 默认布局(= 历史硬编码渲染;后端 DefaultSidebarConfig 的前端镜像)
export function defaultSidebarConfig(): SidebarConfig {
const refs = (keys: string[]): SidebarBlockRef[] => keys.map((key) => ({ key }));
return {
version: 1,
scopes: {
home: {
left: refs(["board_nav"]),
right: refs([
"pulse",
"announcements",
"ads",
"sponsors",
"hot",
"new_users",
"active_users",
"friend_links",
"about_site",
]),
},
board: {
left: [],
right: refs(["board_moderators", "board_intro", "board_pulse", "board_hot", "board_active"]),
},
post: {
left: refs(["post_board", "post_author", "post_author_posts"]),
right: refs(["post_toc"]),
},
},
};
}
// 按页面范围解析左右两列的块列表:
// - 未配置(nil)的侧回落默认布局,显式空数组 = 站长清空该侧;
// - board 范围左栏跟随 home.left(板块态仅管理右栏)。
export function sidebarScopeLists(
config: SidebarConfig,
scope: SidebarScope
): { left: SidebarBlockRef[]; right: SidebarBlockRef[] } {
const def = defaultSidebarConfig().scopes;
if (scope === "board") {
return {
left: config.scopes.home?.left ?? def.home?.left ?? [],
right: config.scopes.board?.right ?? def.board?.right ?? [],
};
}
return {
left: config.scopes[scope]?.left ?? def[scope]?.left ?? [],
right: config.scopes[scope]?.right ?? def[scope]?.right ?? [],
};
}
// 块的展示名:内置取 META,自定义取备注名;未知返回 null(渲染时跳过)
export function sidebarBlockLabel(
ref: SidebarBlockRef,
widgets: { id: number; name: string }[]
): { label: string; custom: boolean } | null {
if (ref.widget) {
const w = widgets.find((x) => x.id === ref.widget);
return w ? { label: w.name, custom: true } : null;
}
if (ref.key) {
for (const scope of Object.keys(SIDEBAR_WIDGET_META) as SidebarScope[]) {
const label = SIDEBAR_WIDGET_META[scope][ref.key];
if (label) return { label, custom: false };
}
}
return null;
}

91
frontend/lib/widgetApi.ts Normal file
View File

@@ -0,0 +1,91 @@
// 自定义侧栏工具的站点开放接口:
// - CSS:工具内直接使用站点主题变量(var(--accent) 等),随明暗主题与
// 后台自定义主题色自动变化(CSS 变量沿 DOM 继承,无需任何 JS);
// - JS:window.J13 提供当前用户、主题读取/监听、站点概览懒加载。
// 本模块只在浏览器端(CustomWidgetBlock 的 effect 内)执行。
import type { OverviewResponse, User } from "@/lib/api";
export interface WidgetViewer {
id: number;
username: string;
nickname: string;
role: string;
avatar: string | null;
}
export interface WidgetApi {
version: 1;
/** 当前登录用户(未登录为 null);每次工具挂载时刷新 */
user: WidgetViewer | null;
theme: {
get: () => "light" | "dark";
/** 监听明暗切换,返回取消监听函数 */
onChange: (cb: (theme: "light" | "dark") => void) => () => void;
};
/** 站点公开概览统计(结果模块级缓存,多个工具共享一次请求) */
getOverview: () => Promise<OverviewResponse | null>;
}
declare global {
interface Window {
J13?: WidgetApi;
}
}
function toViewer(user: User | null | undefined): WidgetViewer | null {
if (!user) return null;
return {
id: user.id,
username: user.username,
nickname: user.nickname,
role: user.role,
avatar: user.avatar || null,
};
}
// 概览请求全站只发一次(无论多少个工具、多少次挂载)
let overviewPromise: Promise<OverviewResponse | null> | null = null;
function loadOverview(): Promise<OverviewResponse | null> {
if (!overviewPromise) {
overviewPromise = fetch("/api/overview", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.catch(() => null);
}
return overviewPromise;
}
/**
* 幂等初始化 window.J13 并刷新当前用户信息。
* 必须在注入站长 JS 之前调用;多个工具共享同一个实例。
*/
export function ensureWidgetApi(viewer: User | null | undefined): WidgetApi {
const root = document.documentElement;
const readTheme = (): "light" | "dark" =>
root.classList.contains("dark") ? "dark" : "light";
if (!window.J13) {
const listeners = new Set<(theme: "light" | "dark") => void>();
// 主题切换 = html 元素 .dark 类的增删,观察其变化广播给工具
const observer = new MutationObserver(() => {
const theme = readTheme();
listeners.forEach((cb) => cb(theme));
});
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
window.J13 = {
version: 1,
user: null,
theme: {
get: readTheme,
onChange(cb) {
listeners.add(cb);
return () => listeners.delete(cb);
},
},
getOverview: loadOverview,
};
}
window.J13.user = toViewer(viewer);
return window.J13;
}

View File

@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { mergeWidgetCode, parseMixedWidgetCode, scopeWidgetCss } from "./widgetCode.ts";
describe("parseMixedWidgetCode", () => {
it("拆分混写的 style/script 段", () => {
const r = parseMixedWidgetCode(
`<div class="a">hi</div>\n<style>.a { color: red; }</style>\n<script>console.log(1);</script>`
);
assert.equal(r.html, `<div class="a">hi</div>`);
assert.equal(r.css, `.a { color: red; }`);
assert.equal(r.js, `console.log(1);`);
});
it("多段 style/script 按序拼接", () => {
const r = parseMixedWidgetCode(`<style>.a{}</style><style>.b{}</style><p>x</p><script>a();</script><script>b();</script>`);
assert.equal(r.css, ".a{}\n\n.b{}");
assert.equal(r.js, "a();\n\nb();");
assert.equal(r.html, "<p>x</p>");
});
it("纯 HTML 不误伤", () => {
const r = parseMixedWidgetCode(`<p>hello</p>`);
assert.deepEqual(r, { html: "<p>hello</p>", css: "", js: "" });
});
it("外链与非 JS type 的 script 留在 html", () => {
const code = `<script src="https://x/y.js"></script><script type="application/json">{"a":1}</script><script>z();</script>`;
const r = parseMixedWidgetCode(code);
assert.equal(r.js, "z();");
assert.equal(
r.html,
`<script src="https://x/y.js"></script><script type="application/json">{"a":1}</script>`
);
});
});
describe("mergeWidgetCode", () => {
it("合并后再解析可还原三字段", () => {
const parts = { html: "<b>go</b>", css: "b { font-size: 18px; }", js: "x();" };
assert.deepEqual(parseMixedWidgetCode(mergeWidgetCode(parts)), parts);
});
it("空段不产生标签", () => {
assert.equal(mergeWidgetCode({ html: "hi", css: "", js: "" }), "hi");
});
});
describe("scopeWidgetCss", () => {
const p = ".j13-widget-7";
it("普通规则加前缀", () => {
assert.equal(scopeWidgetCss(".a { color: red; }", 7), `${p} .a { color: red; }`);
});
it("选择器列表逐个前缀,括号内逗号不分割", () => {
assert.equal(
scopeWidgetCss(".a, .b {}", 7),
`${p} .a, ${p} .b {}`
);
assert.equal(
scopeWidgetCss(':is(.a, .b) { color: red }', 7),
`${p} :is(.a, .b) { color: red }`
);
});
it("root/body 选择器替换为容器本身", () => {
assert.equal(scopeWidgetCss(":root { --x: 1; }", 7), `${p} { --x: 1; }`);
assert.equal(scopeWidgetCss("body { margin: 0 }", 7), `${p} { margin: 0 }`);
assert.equal(scopeWidgetCss("body .card {}", 7), `${p} .card {}`);
});
it("容器自身伪类紧贴前缀", () => {
assert.equal(scopeWidgetCss(":hover { opacity: .8 }", 7), `${p}:hover { opacity: .8 }`);
});
it("@media 内层前缀化,头部原样", () => {
assert.equal(
scopeWidgetCss("@media (min-width: 10px) { .a {} }", 7),
`@media (min-width: 10px) { ${p} .a {} }`
);
});
it("@keyframes 原样保留", () => {
const css = "@keyframes spin { from { opacity: 0 } to { opacity: 1 } }";
assert.equal(scopeWidgetCss(css, 7), css);
});
it("@import 语句原样保留", () => {
assert.equal(
scopeWidgetCss('@import url("a.css"); .a {}', 7),
'@import url("a.css"); .j13-widget-7 .a {}'
);
});
it("注释与字符串内的花括号不干扰配对", () => {
assert.equal(
scopeWidgetCss("/* { nope } */ .a::after { content: \"{\" }", 7),
`/* { nope } */ ${p} .a::after { content: "{" }`
);
});
});

212
frontend/lib/widgetCode.ts Normal file
View File

@@ -0,0 +1,212 @@
// 自定义侧栏工具代码处理:
// - parseMixedWidgetCode / mergeWidgetCode:「混写一框」(内联 <style>/<script>)与分栏三字段互转;
// 三字段存储结构不变,拆分仅发生在管理端提交/切换视图时。
// - scopeWidgetCss:渲染时把工具 CSS 限定在其容器(.j13-widget-{id})内,
// 逐条规则加前缀,避免污染页面其他区域。
export interface WidgetCodeParts {
html: string;
css: string;
js: string;
}
const STYLE_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
const TYPE_RE = /\btype\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
const JS_TYPES = new Set(["", "text/javascript", "application/javascript", "module"]);
function isNonJsScript(attrs: string): boolean {
const m = attrs.match(TYPE_RE);
if (!m) return false;
return !JS_TYPES.has((m[1] ?? m[2] ?? "").toLowerCase());
}
/** 混写代码 → 三字段:<style>/<script> 段被摘出,剩余全部视为 HTML */
export function parseMixedWidgetCode(code: string): WidgetCodeParts {
const cssParts: string[] = [];
const jsParts: string[] = [];
let html = code.replace(STYLE_RE, (_m, body: string) => {
cssParts.push(body);
return "";
});
html = html.replace(SCRIPT_RE, (m: string, attrs: string, body: string) => {
// 外链脚本、模板/数据脚本(type 非 JS)原样留在 HTML 中
if (/\bsrc\s*=/i.test(attrs) || isNonJsScript(attrs)) return m;
jsParts.push(body);
return "";
});
return {
html: html.trim(),
css: cssParts.join("\n\n").trim(),
js: jsParts.join("\n\n").trim(),
};
}
/** 三字段 → 混写视图:HTML 在前,CSS/JS 分别以标准标签包裹 */
export function mergeWidgetCode(parts: WidgetCodeParts): string {
const out: string[] = [];
if (parts.html.trim()) out.push(parts.html.trim());
if (parts.css.trim()) out.push(`<style>\n${parts.css.trim()}\n</style>`);
if (parts.js.trim()) out.push(`<script>\n${parts.js.trim()}\n</script>`);
return out.join("\n\n");
}
/* ─────────────────────────── CSS 作用域 ─────────────────────────── */
// 按 , 分割选择器列表,忽略 () / [] 与引号内的逗号(:is(a,b)、[attr="a,b"])
function splitSelectorList(sel: string): string[] {
const out: string[] = [];
let depth = 0;
let quote = "";
let start = 0;
for (let i = 0; i < sel.length; i++) {
const ch = sel[i];
if (quote) {
if (ch === "\\") i++;
else if (ch === quote) quote = "";
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === "(" || ch === "[") depth++;
else if (ch === ")" || ch === "]") depth = Math.max(0, depth - 1);
else if (ch === "," && depth === 0) {
out.push(sel.slice(start, i));
start = i + 1;
}
}
out.push(sel.slice(start));
return out;
}
// 单个选择器加前缀:
// - :root / :scope / html / body 开头 → 视作容器本身(变量定义在容器上,板块内可用);
// - 以伪类/伪元素(:)开头 → 紧贴容器(容器自身状态,如 .j13-widget-1:hover);
// - 其余 → 后代选择器。
function scopeOneSelector(sel: string, prefix: string): string | null {
const s = sel.trim();
if (!s) return null;
const rootLike = s.match(/^(?::root\b|:scope\b|html\b|body\b)([\s\S]*)$/);
if (rootLike) {
const rest = rootLike[1].replace(/^\s+/, "");
return rest ? `${prefix} ${rest}` : prefix;
}
if (s.startsWith(":")) {
// 功能型伪类开头(:is( / :where( / :has( / :not( )→ 按容器后代处理;
// 简单伪类/伪元素(:hover、::before…)→ 紧贴容器(容器自身状态)。
return /^::?[a-zA-Z-]+\(/.test(s) ? `${prefix} ${s}` : prefix + s;
}
return `${prefix} ${s}`;
}
/**
* 把 CSS 限定到容器内:
* - 普通规则:选择器逐个加 `.j13-widget-{id}` 前缀;
* - @media/@supports/@container/@layer 块:头部原样,内部规则递归前缀化;
* - @keyframes/@font-face/@property 等:整块原样(内部是帧/描述符,不是选择器);
* - 注释、引号内容(如 content:"{")不影响括号配对。
* 已知取舍:同名 @keyframes 在多个工具间仍是全局的,命名请自带区分。
*/
export function scopeWidgetCss(css: string, id: number): string {
const prefix = `.j13-widget-${id}`;
const n = css.length;
let out = "";
let i = 0;
// 从 from 起读一个平衡的 {...} 块(引号与转义内的括号不计入深度)
const readBlock = (from: number): { body: string; end: number } => {
let depth = 0;
let quote = "";
for (let j = from; j < n; j++) {
const ch = css[j];
if (quote) {
if (ch === "\\") j++;
else if (ch === quote) quote = "";
continue;
}
if (ch === '"' || ch === "'") quote = ch;
else if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) return { body: css.slice(from, j + 1), end: j + 1 };
}
}
return { body: css.slice(from), end: n };
};
while (i < n) {
if (css.startsWith("/*", i)) {
const close = css.indexOf("*/", i + 2);
const stop = close === -1 ? n : close + 2;
out += css.slice(i, stop);
i = stop;
continue;
}
if (css[i] === "}") {
// 多余闭括号容错,原样透传
out += css[i];
i++;
continue;
}
// 读取选择器/语句头直到 { ; } 或结尾(引号内不截断)
let j = i;
let quote = "";
for (; j < n; j++) {
const c = css[j];
if (quote) {
if (c === "\\") j++;
else if (c === quote) quote = "";
continue;
}
if (c === '"' || c === "'") {
quote = c;
continue;
}
if (c === "{" || c === ";" || c === "}") break;
}
const head = css.slice(i, j);
if (j >= n) {
out += head;
break;
}
if (css[j] === ";") {
// @import / @charset / @layer a, b; 等语句原样保留
out += head + ";";
i = j + 1;
continue;
}
if (css[j] === "}") {
out += head;
i = j + 1;
continue;
}
const { body, end } = readBlock(j);
const trimmedHead = head.trim();
if (trimmedHead.startsWith("@")) {
const at = trimmedHead.split(/[\s(]/)[0].toLowerCase();
if (at === "@media" || at === "@supports" || at === "@container" || at === "@layer") {
const inner = body.slice(1, -1);
out += head + "{" + scopeWidgetCss(inner, id) + "}";
} else {
out += head + body;
}
} else {
const scoped = splitSelectorList(head)
.map((s) => scopeOneSelector(s, prefix))
.filter((s): s is string => s !== null);
if (scoped.length) {
// 保留选择器前后的原始空白,尽量不改变站长代码的排版
const lead = head.match(/^\s*/)?.[0] ?? "";
const trail = head.match(/\s*$/)?.[0] ?? "";
out += lead + scoped.join(", ") + trail + body;
} else {
out += head + body;
}
}
i = end;
}
return out;
}

View File

@@ -7,7 +7,7 @@
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint",
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts"
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/widgetCode.test.ts"
},
"dependencies": {
"hast-util-to-jsx-runtime": "^2.3.6",