新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
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, 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 }>;
|
||
}
|
||
|
||
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-3 py-2.5"
|
||
style={{ border: "1px solid var(--line)", background: "var(--bg)" }}
|
||
>
|
||
{/* 紧凑两行式:适配左栏窄列,文件名过长截断(悬停可见全名),避免被压成逐字换行 */}
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<ExtChip ext={f.ext} />
|
||
<span
|
||
className="text-[13px] font-medium min-w-0 truncate"
|
||
style={{ color: "var(--ink)" }}
|
||
title={f.name}
|
||
>
|
||
{f.name}
|
||
</span>
|
||
</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 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}
|
||
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"
|
||
variantWidths={[768, 1080, 1920]}
|
||
sizes="(max-width:1280px) 92vw, 1080px"
|
||
/>
|
||
</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}
|
||
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"
|
||
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)" }}>
|
||
{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>
|
||
{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
|
||
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}
|
||
|
||
{/* 附件下载紧跟阅读按钮:左栏桌面端 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
|
||
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>
|
||
</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>
|
||
</>
|
||
);
|
||
}
|