feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等

新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
This commit is contained in:
2026-10-01 03:06:05 +08:00
parent 7fbd6ba699
commit ff2ab286fb
141 changed files with 7527 additions and 1407 deletions

View File

@@ -9,13 +9,14 @@ 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 { libraryDocPath, libraryDocReadPath, userPath } 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";
import Avatar from "@/components/Avatar";
interface PageProps {
params: Promise<{ slug: string }>;
@@ -64,23 +65,27 @@ function ExtChip({ ext }: { ext: string }) {
function FileRow({ f }: { f: LibraryFileItem }) {
return (
<div
className="rounded-xl px-4 py-3"
className="rounded-xl px-3 py-2.5"
style={{ border: "1px solid var(--line)", background: "var(--bg)" }}
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
{/* 紧凑两行式:适配左栏窄列,文件名过长截断(悬停可见全名),避免被压成逐字换行 */}
<div className="flex items-center gap-2 min-w-0">
<ExtChip ext={f.ext} />
<span
className="text-[13.5px] font-medium min-w-0 break-all"
className="text-[13px] font-medium min-w-0 truncate"
style={{ color: "var(--ink)" }}
title={f.name}
>
{f.name}
</span>
<span className="meta text-[11.5px]">
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="meta text-[11.5px] shrink-0">
{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"
className="btn btn-sm btn-primary inline-flex items-center gap-1.5 shrink-0"
>
<Download size={13} /> 下载
</a>
@@ -159,12 +164,23 @@ export default async function LibraryDocPage({ params }: PageProps) {
{/* 横版封面(宽>高且足够大):全宽置于内容上方;竖版或过小的横版图时此槽位不渲染。
小图不强行放大占满全宽:max-w-full 下保持自然尺寸,容器随内容收缩并水平居中 */}
{d.cover_url ? (
<AdaptiveCoverSlot orientation="landscape" src={d.cover_url}>
<AdaptiveCoverSlot
orientation="landscape"
src={d.cover_url}
naturalWidth={d.cover_width}
naturalHeight={d.cover_height}
>
<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" />
<LightboxImage
src={d.cover_url}
alt={d.title}
className="max-w-full h-auto block"
variantWidths={[768, 1080, 1920]}
sizes="(max-width:1280px) 92vw, 1080px"
/>
</div>
</AdaptiveCoverSlot>
) : null}
@@ -175,18 +191,40 @@ export default async function LibraryDocPage({ params }: PageProps) {
{d.cover_url ? (
// 竖版封面(长>宽)及过小的横版封面:按图片自然比例显示在左栏;
// 小图不强行放大占满:保持自然尺寸,在槽位最大区域内水平垂直居中
<AdaptiveCoverSlot orientation="portrait" src={d.cover_url}>
<AdaptiveCoverSlot
orientation="portrait"
src={d.cover_url}
naturalWidth={d.cover_width}
naturalHeight={d.cover_height}
>
<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" />
<LightboxImage
src={d.cover_url}
alt={d.title}
className="max-w-full h-auto block"
variantWidths={[384, 768]}
sizes="(max-width:1023px) 92vw, 340px"
/>
</div>
</AdaptiveCoverSlot>
) : (
<CoverFallback title={d.title} />
)}
<dl className="mt-4 flex flex-col gap-2 text-[13px]">
{d.author ? (
<div className="flex items-start justify-between gap-3">
<dt className="meta shrink-0">作者</dt>
<dd
className="font-semibold text-right min-w-0"
style={{ color: "var(--ink)" }}
>
{d.author}
</dd>
</div>
) : null}
<div className="flex items-center justify-between">
<dt className="meta">章节数</dt>
<dd className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
@@ -199,6 +237,30 @@ export default async function LibraryDocPage({ params }: PageProps) {
{d.files.length}
</dd>
</div>
{d.creator ? (
<div className="flex items-center justify-between gap-2">
<dt className="meta shrink-0">创建者</dt>
<dd className="min-w-0">
<Link
href={userPath(urlStyle, d.creator.id)}
className="inline-flex items-center gap-1.5 min-w-0"
title={`@${d.creator.username}`}
>
<Avatar
size={20}
name={d.creator.nickname || d.creator.username}
src={d.creator.avatar}
/>
<span
className="text-[12.5px] font-medium truncate max-w-[9rem]"
style={{ color: "var(--ink)" }}
>
{d.creator.nickname || d.creator.username}
</span>
</Link>
</dd>
</div>
) : null}
</dl>
{chapterCount > 0 ? (
<Link
@@ -208,9 +270,28 @@ export default async function LibraryDocPage({ params }: PageProps) {
<BookOpen size={15} aria-hidden /> 开始阅读
</Link>
) : null}
{/* 附件下载紧跟阅读按钮:左栏桌面端 sticky 常驻,移动端排在介绍之前,
避免介绍过长时用户需要长距离滚动寻找下载入口 */}
{d.files.length > 0 ? (
<section id="files" className="mt-5 scroll-mt-24">
<h2
className="text-[13.5px] font-bold inline-flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Download size={14} style={{ color: "var(--accent)" }} aria-hidden />
附件下载({d.files.length})
</h2>
<div className="mt-2.5 flex flex-col gap-2">
{d.files.map((f) => (
<FileRow key={f.id} f={f} />
))}
</div>
</section>
) : null}
</aside>
{/* 右栏:介绍 + 附件 */}
{/* 右栏:介绍(附件下载已移至左栏阅读按钮下方) */}
<div className="mt-6 lg:mt-0 min-w-0">
<section>
<h2
@@ -228,25 +309,6 @@ export default async function LibraryDocPage({ params }: PageProps) {
)}
</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>

View File

@@ -21,7 +21,9 @@ 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 }>;
@@ -127,6 +129,18 @@ export default async function LibraryReadPage({ params, searchParams }: PageProp
[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">
@@ -247,11 +261,19 @@ export default async function LibraryReadPage({ params, searchParams }: PageProp
{/* 中间:单章正文 + 上下章导航(占满中栏全宽;手机端 panel 贴边铺满) */}
<div className="j13-doc-bleed min-w-0">
<article className="panel p-6 sm:p-8">
<article id="j13-book-article" className="panel p-6 sm:p-8">
<header>
<p className="meta text-[12px]">
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
</p>
<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"

View File

@@ -1,106 +1,9 @@
import type { Metadata } from "next";
import type { CSSProperties } from "react";
import Link from "next/link";
import { BookOpen, FileDown } from "lucide-react";
import { fetchLibraryList, type LibraryDocListItem } from "@/lib/api";
import { coverHue } from "@/lib/coverColor";
import { getPublicSettingsCached } from "@/lib/serverData";
import { requestCanonical } from "@/lib/canonicalMeta";
import { libraryPath, libraryDocPath } from "@/lib/urlStyle";
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`;
}
/** 文字封面:无图时用完整书名 + 书名派生稳定色相洗底(lib/coverColor.ts) */
function TextCover({ title }: { title: string }) {
const name = title.trim() || "书";
return (
<div
className="j13-text-cover w-full h-full flex items-center justify-center px-5 select-none"
style={{ "--cover-h": coverHue(name) } as CSSProperties}
aria-hidden
>
<span
className="text-[22px] font-extrabold leading-snug text-center line-clamp-3 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"
style={{
color: "var(--accent)",
background: "var(--accent-soft)",
border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)",
}}
>
{ext}
</span>
);
}
function DocCard({
doc,
href,
}: {
doc: LibraryDocListItem;
href: string;
}) {
const exts = Array.from(new Set(doc.files.map((f) => f.ext))).slice(0, 4);
const totalSize = doc.files.reduce((acc, f) => acc + f.size, 0);
return (
<Link
href={href}
className="group flex flex-col overflow-hidden rounded-[20px] transition-transform hover:-translate-y-0.5"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
}}
>
<div className="h-36 overflow-hidden" style={{ borderBottom: "1px solid var(--line)" }}>
{doc.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={doc.cover_url}
alt={doc.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
decoding="async"
draggable={false}
/>
) : (
<TextCover title={doc.title} />
)}
</div>
<div className="p-4 flex flex-col gap-2 flex-1">
<h2
className="text-[15px] font-bold leading-snug line-clamp-2"
style={{ color: "var(--ink)" }}
>
{doc.title}
</h2>
<div className="mt-auto flex flex-wrap items-center gap-1.5">
{exts.map((ext) => (
<ExtChip key={ext} ext={ext} />
))}
<span className="meta text-[11px] ml-auto inline-flex items-center gap-1">
<FileDown size={11} />
{doc.file_count > 0 ? `${doc.file_count} 个文件 · ${formatSize(totalSize)}` : "暂无文件"}
</span>
</div>
</div>
</Link>
);
}
import { libraryPath } from "@/lib/urlStyle";
import LibrarySearchGrid from "@/components/LibrarySearchGrid";
export async function generateMetadata(): Promise<Metadata> {
const settings = await getPublicSettingsCached();
@@ -113,7 +16,6 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function LibraryPage() {
const settings = await getPublicSettingsCached();
const urlStyle = settings.url_style;
// 接口失败降级为空态,不让页面 500
let docs: LibraryDocListItem[] = [];
@@ -126,53 +28,52 @@ export default async function LibraryPage() {
return (
<div className="min-w-0">
{/* Hero:渐变洗底(与排行榜同语言) */}
{/* Hero:书香气——serif 大标题、两侧晕染、底部水纹线 */}
<section
className="relative overflow-hidden rounded-[28px] px-6 py-8 sm:px-10 sm:py-10 text-center"
className="relative overflow-hidden px-6 py-12 sm:px-12 sm:py-16 text-center"
style={{
background:
"radial-gradient(120% 160% at 12% -10%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 55%)," +
"radial-gradient(90% 140% at 90% -20%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 50%)",
border: "1px solid var(--line)",
"radial-gradient(140% 180% at 50% -20%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 60%)," +
"radial-gradient(80% 120% at 8% 0%, color-mix(in srgb, var(--accent) 6%, transparent), transparent 50%)," +
"radial-gradient(80% 120% at 92% 0%, color-mix(in srgb, var(--accent) 6%, transparent), transparent 50%)",
}}
>
<div
aria-hidden
className="absolute -top-16 -right-10 w-56 h-56 rounded-full pointer-events-none"
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[520px] h-[520px] rounded-full pointer-events-none"
style={{
background: "color-mix(in srgb, var(--accent) 14%, transparent)",
filter: "blur(48px)",
background: "color-mix(in srgb, var(--accent) 10%, transparent)",
filter: "blur(72px)",
}}
/>
<div className="relative">
<p
className="text-[11px] sm:text-[12px] font-semibold tracking-[0.35em] uppercase mb-4"
style={{ color: "var(--accent)" }}
>
Library
</p>
<h1
className="text-[26px] sm:text-[32px] leading-tight font-extrabold tracking-tight inline-flex items-center gap-2.5"
className="j13-library-title text-[32px] sm:text-[44px] leading-tight font-bold tracking-wide"
style={{ color: "var(--ink)" }}
>
<BookOpen size={26} style={{ color: "var(--accent)" }} />
书库
</h1>
<p className="meta mt-2 text-[13.5px]">书籍、文档与页面作品,在线阅读或下载</p>
<div
aria-hidden
className="mx-auto mt-5 h-px w-24 sm:w-32"
style={{
background:
"linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 40%, var(--line)), transparent)",
}}
/>
<p className="meta mt-4 text-[13.5px] sm:text-[14px] tracking-wide">
书籍、文档与页面作品,在线阅读或下载
</p>
</div>
</section>
{docs.length === 0 ? (
<div
className="mt-6 rounded-[20px] px-6 py-14 text-center"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<p className="text-[14px] font-medium" style={{ color: "var(--ink-2)" }}>
书库还没有内容
</p>
<p className="meta text-[12.5px] mt-1.5">管理员可在后台「书库管理」发布条目</p>
</div>
) : (
<div className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{docs.map((d) => (
<DocCard key={d.id} doc={d} href={libraryDocPath(urlStyle, d.slug)} />
))}
</div>
)}
<LibrarySearchGrid docs={docs} urlStyle={settings.url_style} />
</div>
);
}