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