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"
|
||||
|
||||
Reference in New Issue
Block a user