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; 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 { 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 ( {children} ); }; return { h2: render("h2"), h3: render("h3") }; } export async function generateMetadata({ params, searchParams }: PageProps): Promise { 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 = (
书籍详情

{d.title}

{nav.total} 章 · 更新于 {formatRelative(d.updated_at)}

); return (
{/* 移动端:吸顶工具栏 + 左右抽屉(目录 / 本章),lg 起隐藏;返回详情入口在左抽屉内 */}

{current.title}

第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟

} left={{ label: "目录", content: bookRail }} right={ hasChapterToc ? { label: "本章", // 抽屉容器是无高度约束的滚动区,需包一层 h-full 限高 flex, // PostToc 内列表才能成为滚动容器:否则列表不滚动(overscroll-behavior // 阻断滚动链到抽屉容器)且跟随阅读位置的定位滚动落空 content: (
), } : undefined } />
{/* 左侧书栏:详情入口 + 章节目录树(与移动端左抽屉共用 bookRail 内容) */} {/* 中间:单章正文 + 上下章导航(占满中栏全宽;手机端 panel 贴边铺满) */}

第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟

{current.title}

{current.content ? (
) : null} {subs.map((sub, si) => (

{sub.title}

{sub.content ? (
) : null}
))}
{/* 右栏:章节内目录树(区域足够时宽屏常驻,滚动时可随时跳转)。 外层仅做 sticky 定位(flex-col + max-h 限高),滚动由 PostToc 内列表承担: 避免外层 overflow-y:auto 与内层列表的 overflow-y:auto 形成双层滚动容器, 滚轮路由到不可滚的内层导致滚轮失效(只能拖滚动条) */} {hasChapterToc ? ( ) : null}
); }