feat: 友链/书库/移动端导航/排行榜等多模块功能

新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
2026-09-30 16:30:07 +08:00
parent e487bc2e92
commit 4f3f3a265c
105 changed files with 12371 additions and 852 deletions

View File

@@ -0,0 +1,360 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, ArrowRight } from "lucide-react";
import type { Components } from "react-markdown";
import {
fetchLibraryDocBySlug,
type LibraryDoc,
type LibrarySectionItem,
} from "@/lib/api";
import { getPublicSettingsCached } from "@/lib/serverData";
import MarkdownBody from "@/components/MarkdownBody";
import { libraryDocPath, libraryDocReadPath } from "@/lib/urlStyle";
import {
chapterNav,
groupSections,
type ChapterNav,
} from "@/lib/librarySections";
import { extractToc, estimateReadingMinutes, markdownHeadingToc, buildTocFromSegments } from "@/lib/toc";
import { splitContentSegments } from "@/lib/directiveBlocks";
import { wrapImplicitEntries } from "@/lib/entryBlocks";
import PostToc, { type TocItem } from "@/components/PostToc";
import MobileRailDrawers from "@/components/MobileRailDrawers";
import { formatRelative } from "@/lib/format";
interface PageProps {
params: Promise<{ slug: string }>;
searchParams: Promise<{ c?: string }>;
}
type SectionGroups = ReturnType<typeof groupSections>;
interface ReadData {
doc: LibraryDoc;
groups: SectionGroups;
current: LibrarySectionItem | null;
nav: ChapterNav;
}
/**
* 读页共享取数(generateMetadata 与 page 共用):
* fetch 失败/404 → null;?c 为书内章序号(1 起,可写入文章目录地址),
* 越界/缺省回落第一章;旧链接传章节全局 id 时兜底按 id 匹配(不报错)
*/
async function loadRead(slug: string, cParam?: string): Promise<ReadData | null> {
const data = await fetchLibraryDocBySlug(slug).catch(() => null);
if (!data) return null;
const groups = groupSections(data.doc.sections ?? []);
const c = Number.parseInt(cParam ?? "", 10);
const byNo =
Number.isFinite(c) && c >= 1 && c <= groups.length ? groups[c - 1].chapter : undefined;
const byId = Number.isFinite(c) ? groups.find((g) => g.chapter.id === c)?.chapter : undefined;
const current = byNo ?? byId ?? groups[0]?.chapter ?? null;
return { doc: data.doc, groups, current, nav: chapterNav(groups, current?.id ?? null) };
}
/** 为内容块的 markdown 标题挂章内锚点 id(ids 按标题出现顺序对应,与本章目录共享同一份) */
function headingComponentsFor(content: string, ids: string[]): Components | undefined {
const toc = markdownHeadingToc(extractToc(content || ""));
if (toc.length === 0) return undefined;
let idx = 0;
const render = (Tag: "h2" | "h3") =>
function MdHeading({ children }: { children?: React.ReactNode }) {
const item = toc[idx++];
return (
<Tag id={item ? ids[idx - 1] : undefined} style={{ scrollMarginTop: 90 }}>
{children}
</Tag>
);
};
return { h2: render("h2"), h3: render("h3") };
}
export async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
const { slug } = await params;
const { c } = await searchParams;
const data = await loadRead(slug, c);
if (!data) return { title: "条目不存在" };
const title = data.current ? `${data.current.title} · ${data.doc.title}` : data.doc.title;
const description = (data.current?.content || data.doc.title).slice(0, 150);
// 全部章节共用一个 URL,避免重复收录;详情页是唯一索引入口
return { title, description, robots: { index: false } };
}
export default async function LibraryReadPage({ params, searchParams }: PageProps) {
const [{ slug }, { c }] = await Promise.all([params, searchParams]);
const [data, settings] = await Promise.all([loadRead(slug, c), getPublicSettingsCached()]);
if (!data) notFound();
const urlStyle = settings.url_style;
const d = data.doc;
// 无章节条目:阅读页无内容可渲染,回详情页(介绍 + 附件)
if (!data.current) redirect(libraryDocPath(urlStyle, d.slug));
const current = data.current;
const { groups, nav } = data;
const subs = groups[nav.index]?.subs ?? [];
// 章内锚点统一 e-{sec}-{n}:sec 为书内章序号(与地址 ?sec= 一致),n 为章内 1 基顺序号,
// 按渲染顺序(章首 markdown 标题/条目卡标题 → 各小节及其标题/条目)由同一计数器分配,
// 目录、正文 markdown 标题、卡内条目标题共享同一份 id,可手写目录链接如 ?sec=1#e-1-1
// 条目卡模式先做与渲染一致的规范化(标题包壳后再提取),避免目录锚点错位
const normOf = (content: string) =>
d.entries_auto ? wrapImplicitEntries(content || "") : content || "";
const segsOf = (content: string) =>
splitContentSegments(normOf(content), { entriesAuto: d.entries_auto });
const secNo = nav.index + 1;
let anchorSeq = 0;
const nextAnchor = () => `e-${secNo}-${++anchorSeq}`;
const chToc = buildTocFromSegments(segsOf(current.content), nextAnchor);
// 小节:titleId 仍由页面 h3 直接挂载,其内容目录来自分段构建
const subAnchors = subs.map((sub) => ({
titleId: nextAnchor(),
toc: buildTocFromSegments(segsOf(sub.content), nextAnchor),
}));
// 右栏目录不重复章名(正文标题、左栏章节列表均已可见),章首条目/小节/小节内标题直接铺开,
// 省掉章名占位行,让有限宽度的每行能显示更长的标题
const chapterToc: TocItem[] = [...chToc.items];
subAnchors.forEach((a, si) => {
chapterToc.push({ level: 2, text: subs[si].title, id: a.titleId });
chapterToc.push(...a.toc.items);
});
const hasChapterToc = chapterToc.length > 0;
const detail = libraryDocPath(urlStyle, d.slug);
const chapterMinutes = estimateReadingMinutes(
[current.content, ...subs.map((s) => s.content)].filter(Boolean).join("\n\n")
);
// 书籍侧栏内容:桌面左栏与移动端左抽屉共用(详情入口 + 章节列表)
const bookRail = (
<div className="flex flex-col gap-3">
<Link
href={detail}
className="inline-flex items-center gap-1.5 meta text-[12px] shrink-0 hover:text-[var(--accent)]"
>
<ArrowLeft size={13} aria-hidden /> 书籍详情
</Link>
<Link
href={detail}
className="block rounded-xl p-1 -m-1 hover:bg-[var(--panel-2)] transition-colors"
>
<div className="min-w-0">
<p
className="text-[13.5px] font-bold leading-snug line-clamp-2"
style={{ color: "var(--ink)" }}
>
{d.title}
</p>
<p className="meta text-[11.5px] mt-1">
{nav.total} 章 · 更新于 {formatRelative(d.updated_at)}
</p>
</div>
</Link>
<nav aria-label="章节目录">
<p
className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-2 px-1"
style={{ color: "var(--ink-3)" }}
>
目录
</p>
<ul className="space-y-0.5" style={{ borderLeft: "1px solid var(--line)" }}>
{groups.map((g, i) => {
const active = g.chapter.id === current.id;
return (
<li key={g.chapter.id}>
<Link
href={libraryDocReadPath(urlStyle, d.slug, i + 1)}
className={`j13-toc-link${active ? " is-active" : ""}`}
aria-current={active ? "location" : undefined}
>
<span className="flex items-center gap-1.5 min-w-0">
<span className="min-w-0 flex-1 truncate">{g.chapter.title}</span>
</span>
</Link>
{/* 当前章的节:页内锚点(e-{sec}-{n});其他章的节:跳到该章 */}
{g.subs.map((sub, si) =>
active ? (
<a key={sub.id} href={`#${subAnchors[si].titleId}`} className="j13-toc-link is-h3">
{sub.title}
</a>
) : (
<Link
key={sub.id}
href={libraryDocReadPath(urlStyle, d.slug, i + 1)}
className="j13-toc-link is-h3"
>
{sub.title}
</Link>
)
)}
</li>
);
})}
</ul>
</nav>
</div>
);
return (
<div className="min-w-0">
{/* 移动端:吸顶工具栏 + 左右抽屉(目录 / 本章),lg 起隐藏;返回详情入口在左抽屉内 */}
<MobileRailDrawers
center={
<>
<p className="truncate text-[13px] font-bold leading-tight" style={{ color: "var(--ink)" }}>
{current.title}
</p>
<p className="meta mt-0.5 text-[10.5px] leading-tight">
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
</p>
</>
}
left={{ label: "目录", content: bookRail }}
right={
hasChapterToc
? {
label: "本章",
// 抽屉容器是无高度约束的滚动区,需包一层 h-full 限高 flex,
// PostToc 内列表才能成为滚动容器:否则列表不滚动(overscroll-behavior
// 阻断滚动链到抽屉容器)且跟随阅读位置的定位滚动落空
content: (
<div className="flex h-full min-h-0 flex-col">
<PostToc items={chapterToc} title="本章目录" bare />
</div>
),
}
: undefined
}
/>
<div
className={
hasChapterToc
? "lg:grid lg:grid-cols-[280px_minmax(0,1fr)] lg:gap-8 xl:grid-cols-[280px_minmax(0,1fr)_280px] xl:gap-6"
: "lg:grid lg:grid-cols-[280px_minmax(0,1fr)] xl:grid-cols-[320px_minmax(0,1fr)] lg:gap-8"
}
>
{/* 左侧书栏:详情入口 + 章节目录树(与移动端左抽屉共用 bookRail 内容) */}
<aside className="hidden lg:block min-w-0">
<div className="sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)] overflow-y-auto j13-rail-scroll panel p-4">
{bookRail}
</div>
</aside>
{/* 中间:单章正文 + 上下章导航(占满中栏全宽;手机端 panel 贴边铺满) */}
<div className="j13-doc-bleed min-w-0">
<article className="panel p-6 sm:p-8">
<header>
<p className="meta text-[12px]">
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
</p>
<h2
id="ch-top"
className="mt-1.5 scroll-mt-24 text-[24px] sm:text-[26px] font-extrabold leading-snug"
style={{ color: "var(--ink)" }}
>
{current.title}
</h2>
</header>
{current.content ? (
<div className="mt-5">
<MarkdownBody
content={current.content}
components={headingComponentsFor(normOf(current.content), chToc.mdHeadIds)}
entriesAuto={d.entries_auto}
entriesTitleIds={chToc.entriesTitleIds}
/>
</div>
) : null}
{subs.map((sub, si) => (
<section key={sub.id} className="mt-8">
<h3
id={subAnchors[si].titleId}
className="scroll-mt-24 text-[17px] sm:text-[18px] font-bold"
style={{ color: "var(--ink)" }}
>
{sub.title}
</h3>
{sub.content ? (
<div className="mt-3">
<MarkdownBody
content={sub.content}
components={headingComponentsFor(normOf(sub.content), subAnchors[si].toc.mdHeadIds)}
entriesAuto={d.entries_auto}
entriesTitleIds={subAnchors[si].toc.entriesTitleIds}
/>
</div>
) : null}
</section>
))}
</article>
<nav className="mt-5 grid gap-3 sm:grid-cols-2" aria-label="章节导航">
{nav.prev ? (
<Link
href={libraryDocReadPath(urlStyle, d.slug, nav.index)}
className="panel p-4 min-w-0 hover:bg-[var(--panel-2)] transition-colors"
>
<span className="meta text-[11.5px] inline-flex items-center gap-1">
<ArrowLeft size={12} aria-hidden /> 上一章
</span>
<span
className="mt-1 block text-[13.5px] font-semibold truncate"
style={{ color: "var(--ink)" }}
>
{nav.prev.title}
</span>
</Link>
) : (
<div className="panel p-4 opacity-50 select-none">
<span className="meta text-[11.5px]">上一章</span>
<span className="mt-1 block text-[13.5px] font-semibold" style={{ color: "var(--ink-3)" }}>
已是第一章
</span>
</div>
)}
{nav.next ? (
<Link
href={libraryDocReadPath(urlStyle, d.slug, nav.index + 2)}
className="panel p-4 min-w-0 hover:bg-[var(--panel-2)] transition-colors sm:text-right"
>
<span className="meta text-[11.5px] inline-flex items-center gap-1">
下一章 <ArrowRight size={12} aria-hidden />
</span>
<span
className="mt-1 block text-[13.5px] font-semibold truncate"
style={{ color: "var(--ink)" }}
>
{nav.next.title}
</span>
</Link>
) : (
<div className="panel p-4 opacity-50 select-none sm:text-right">
<span className="meta text-[11.5px]">下一章</span>
<span className="mt-1 block text-[13.5px] font-semibold" style={{ color: "var(--ink-3)" }}>
已是最后一章
</span>
</div>
)}
</nav>
</div>
{/* 右栏:章节内目录树(区域足够时宽屏常驻,滚动时可随时跳转)。
外层仅做 sticky 定位(flex-col + max-h 限高),滚动由 PostToc 内列表承担:
避免外层 overflow-y:auto 与内层列表的 overflow-y:auto 形成双层滚动容器,
滚轮路由到不可滚的内层导致滚轮失效(只能拖滚动条) */}
{hasChapterToc ? (
<aside className="hidden xl:block min-w-0">
<div className="sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)] flex flex-col">
<PostToc items={chapterToc} title="本章目录" bare />
</div>
</aside>
) : null}
</div>
</div>
);
}