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,261 @@
import type { Metadata } from "next";
import type { CSSProperties } from "react";
import { notFound } from "next/navigation";
import Link from "next/link";
import { BookOpen, Download, Paperclip } from "lucide-react";
import { fetchLibraryDocBySlug, type LibraryFileItem } from "@/lib/api";
import { coverHue } from "@/lib/coverColor";
import { getPublicSettingsCached } from "@/lib/serverData";
import MarkdownBody from "@/components/MarkdownBody";
import { SiteDocMasthead } from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { libraryDocPath, libraryDocReadPath } from "@/lib/urlStyle";
import { webPageJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
import { estimateReadingMinutes } from "@/lib/toc";
import { groupSections } from "@/lib/librarySections";
import LightboxImage from "@/components/LightboxImage";
import AdaptiveCoverSlot from "@/components/AdaptiveCoverSlot";
interface PageProps {
params: Promise<{ slug: string }>;
}
function formatSize(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0 B";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/** 无封面时:完整书名 + 书名派生稳定色相渐变块(有图封面走 AdaptiveCoverSlot 按横竖版自适应摆放) */
function CoverFallback({ title }: { title: string }) {
const name = title.trim() || "书";
return (
<div
className="j13-text-cover w-full aspect-[3/4] rounded-2xl flex items-center justify-center px-6 select-none"
style={{ "--cover-h": coverHue(name) } as CSSProperties}
aria-hidden
>
<span className="text-[26px] font-extrabold leading-relaxed text-center line-clamp-5 break-all">
{name}
</span>
</div>
);
}
// 附件仅下载分发(书籍正文走阅读页逐章阅读)
function ExtChip({ ext }: { ext: string }) {
return (
<span
className="text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide shrink-0"
style={{
color: "var(--accent)",
background: "var(--accent-soft)",
border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)",
}}
>
{ext}
</span>
);
}
function FileRow({ f }: { f: LibraryFileItem }) {
return (
<div
className="rounded-xl px-4 py-3"
style={{ border: "1px solid var(--line)", background: "var(--bg)" }}
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<ExtChip ext={f.ext} />
<span
className="text-[13.5px] font-medium min-w-0 break-all"
style={{ color: "var(--ink)" }}
>
{f.name}
</span>
<span className="meta text-[11.5px]">
{formatSize(f.size)} · {f.download_count} 次下载
</span>
<a
href={`/api/library/files/${f.id}/download`}
className="btn btn-sm btn-primary ml-auto inline-flex items-center gap-1.5 shrink-0"
>
<Download size={13} /> 下载
</a>
</div>
</div>
);
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const data = await fetchLibraryDocBySlug(slug).catch(() => null);
if (!data) return { title: "条目不存在" };
const d = data.doc;
return {
title: d.title,
description: d.description ? d.description.slice(0, 150) : d.title,
openGraph: {
title: d.title,
description: d.description ? d.description.slice(0, 150) : d.title,
type: "article",
publishedTime: d.created_at,
},
...(await requestCanonical(libraryDocPath((await getPublicSettingsCached()).url_style, d.slug))),
};
}
export default async function LibraryDocPage({ params }: PageProps) {
const { slug } = await params;
const [data, settings] = await Promise.all([
fetchLibraryDocBySlug(slug).catch(() => null),
getPublicSettingsCached(),
]);
if (!data) notFound();
const d = data.doc;
const urlStyle = settings.url_style;
const chapterCount = groupSections(d.sections ?? []).length;
const readingMinutes = estimateReadingMinutes(
[d.description, ...(d.sections ?? []).map((s) => s.content)].filter(Boolean).join("\n\n")
);
const jsonLd = webPageJsonLd({
name: d.title,
description: d.description ? d.description.slice(0, 150) : undefined,
path: libraryDocPath(urlStyle, d.slug),
datePublished: d.created_at,
dateModified: d.updated_at,
});
return (
<>
<JsonLd data={jsonLd} />
<div className="max-w-[1200px] 2xl:max-w-[1320px] mx-auto min-w-0">
{/* 手机端水平内边距收窄(px-3),介绍内容近全宽;sm 起恢复常规面板内距 */}
<article className="panel px-3 py-5 sm:p-8">
<SiteDocMasthead
kicker="书库"
title={d.title}
createdAt={d.created_at}
updatedAt={d.updated_at}
showTimeOfDay
readingMinutes={readingMinutes}
badges={
chapterCount > 0 ? (
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[11.5px] font-semibold"
style={{ background: "var(--panel-2)", color: "var(--ink-2)", border: "1px solid var(--line)" }}
>
{chapterCount} 章
</span>
) : undefined
}
/>
{/* 横版封面(宽>高且足够大):全宽置于内容上方;竖版或过小的横版图时此槽位不渲染。
小图不强行放大占满全宽:max-w-full 下保持自然尺寸,容器随内容收缩并水平居中 */}
{d.cover_url ? (
<AdaptiveCoverSlot orientation="landscape" src={d.cover_url}>
<div
className="mt-6 w-fit max-w-full mx-auto overflow-hidden rounded-2xl"
style={{ border: "1px solid var(--line)" }}
>
<LightboxImage src={d.cover_url} alt={d.title} className="max-w-full h-auto block" />
</div>
</AdaptiveCoverSlot>
) : null}
<div className="mt-6 lg:grid lg:grid-cols-[minmax(0,340px)_minmax(0,1fr)] lg:gap-8">
{/* 左栏:封面 + 元信息 + 开始阅读 */}
<aside className="lg:sticky lg:top-[84px] self-start">
{d.cover_url ? (
// 竖版封面(长>宽)及过小的横版封面:按图片自然比例显示在左栏;
// 小图不强行放大占满:保持自然尺寸,在槽位最大区域内水平垂直居中
<AdaptiveCoverSlot orientation="portrait" src={d.cover_url}>
<div
className="w-fit max-w-full mx-auto overflow-hidden rounded-2xl"
style={{ border: "1px solid var(--line)" }}
>
<LightboxImage src={d.cover_url} alt={d.title} className="max-w-full h-auto block" />
</div>
</AdaptiveCoverSlot>
) : (
<CoverFallback title={d.title} />
)}
<dl className="mt-4 flex flex-col gap-2 text-[13px]">
<div className="flex items-center justify-between">
<dt className="meta">章节数</dt>
<dd className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
{chapterCount}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="meta">附件数</dt>
<dd className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
{d.files.length}
</dd>
</div>
</dl>
{chapterCount > 0 ? (
<Link
href={libraryDocReadPath(urlStyle, d.slug)}
className="btn btn-primary mt-4 w-full justify-center inline-flex items-center gap-2"
>
<BookOpen size={15} aria-hidden /> 开始阅读
</Link>
) : null}
</aside>
{/* 右栏:介绍 + 附件 */}
<div className="mt-6 lg:mt-0 min-w-0">
<section>
<h2
className="text-[15px] font-bold inline-flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Paperclip size={15} style={{ color: "var(--accent)" }} aria-hidden />
介绍
</h2>
<div className="mt-3">
{d.description ? (
<MarkdownBody content={d.description} />
) : (
<p className="meta text-[13px]">这本书还没有介绍。</p>
)}
</div>
</section>
<section id="files" className="mt-9 scroll-mt-24">
<h2
className="text-[15px] font-bold inline-flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Download size={15} style={{ color: "var(--accent)" }} aria-hidden />
附件下载({d.files.length})
</h2>
{d.files.length === 0 ? (
<p className="meta text-[12.5px] mt-3">暂无附件</p>
) : (
<div className="mt-3 flex flex-col gap-2.5">
{d.files.map((f) => (
<FileRow key={f.id} f={f} />
))}
</div>
)}
</section>
</div>
</div>
</article>
<div className="mt-5 flex items-center gap-2 text-[12.5px] meta">
<BookOpen size={14} style={{ color: "var(--accent)" }} aria-hidden />
<span>来自站点书库</span>
</div>
</div>
</>
);
}

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