feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user