新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
383 lines
16 KiB
TypeScript
383 lines
16 KiB
TypeScript
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 BookSearch from "@/components/BookSearch";
|
||
import { formatRelative } from "@/lib/format";
|
||
import { markdownToSearchText, type BookChapterIndex } from "@/lib/bookSearch";
|
||
|
||
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")
|
||
);
|
||
|
||
// 全书搜索索引:详情接口已一次返回全部章节,这里把每章(含小节标题/正文)
|
||
// 剥成可见纯文本随 RSC 下发给 BookSearch,无需额外交互请求
|
||
const searchIndex: BookChapterIndex[] = groups.map((g, i) => ({
|
||
no: i + 1,
|
||
title: g.chapter.title,
|
||
plain: markdownToSearchText(
|
||
[g.chapter.title, g.chapter.content, ...g.subs.flatMap((s) => [s.title, 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 id="j13-book-article" className="panel p-6 sm:p-8">
|
||
<header>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<p className="meta text-[12px]">
|
||
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
|
||
</p>
|
||
<BookSearch
|
||
slug={d.slug}
|
||
urlStyle={urlStyle}
|
||
currentNo={nav.index + 1}
|
||
chapters={searchIndex}
|
||
/>
|
||
</div>
|
||
<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>
|
||
);
|
||
}
|