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

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