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

@@ -0,0 +1,241 @@
"use client";
import { useMemo, useState, type CSSProperties } from "react";
import Link from "next/link";
import { FileDown, PenLine, Search, X } from "lucide-react";
import type { LibraryDocListItem } from "@/lib/api";
import { coverHue } from "@/lib/coverColor";
import { libraryDocPath, type UrlStyle } from "@/lib/urlStyle";
import { variantURL, widthSrcSet } from "@/lib/responsiveImage";
import { formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
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`;
}
/** 简介 markdown → 纯文本摘要(去标记、压平空白,卡片两行截断用) */
function plainExcerpt(md: string): string {
return md
.replace(/!\[[^\]]*\]\([^)]*\)/g, "") // 图片
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // 链接保留文字
.replace(/[*_~`#>]+/g, "") // 行内/块级标记
.replace(/\s+/g, " ")
.trim();
}
/** 文字封面:无图时用完整书名 + 书名派生稳定色相洗底(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-4 select-none"
style={{ "--cover-h": coverHue(name) } as CSSProperties}
aria-hidden
>
<span className="j13-library-title text-[19px] font-bold leading-snug text-center line-clamp-4 break-all tracking-wide">
{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);
const creatorName = doc.creator ? doc.creator.nickname || doc.creator.username : "";
const excerpt = plainExcerpt(doc.description ?? "");
return (
<Link href={href} className="group flex flex-col min-w-0">
{/* 竖版书形封面:书脊光影 + 悬停浮出简介 */}
<div
className="relative aspect-[3/4] overflow-hidden rounded-[10px] shadow-[var(--shadow-sm)] transition-[transform,box-shadow] duration-200 group-hover:-translate-y-1.5 group-hover:shadow-[var(--shadow-lg)]"
style={{ background: "var(--panel-2)" }}
>
{doc.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={variantURL(doc.cover_url, 384)}
srcSet={widthSrcSet(doc.cover_url, [384, 512, 768])}
sizes="(max-width:640px) 46vw, (max-width:1024px) 30vw, (max-width:1280px) 22vw, 220px"
alt={doc.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
draggable={false}
/>
) : (
<TextCover title={doc.title} />
)}
{/* 书脊/书口光影,营造实体书体积感 */}
<div
aria-hidden
className="absolute inset-0 rounded-[10px] pointer-events-none"
style={{
background:
"linear-gradient(90deg, rgba(0,0,0,0.16) 0%, rgba(0,0,0,0.05) 2.5%, transparent 5%, transparent 96%, rgba(0,0,0,0.05) 98.5%, rgba(0,0,0,0.1) 100%)",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.06)",
}}
/>
{excerpt ? (
<div
className="absolute inset-0 flex items-end p-3.5 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none"
style={{
background:
"linear-gradient(to top, rgba(18,20,26,0.92) 0%, rgba(18,20,26,0.55) 55%, rgba(18,20,26,0.15) 100%)",
}}
>
<p className="text-[12px] leading-relaxed text-white/90 line-clamp-5">{excerpt}</p>
</div>
) : null}
</div>
{/* 封面下方书目信息:衬线书名 + 作者 + 文件概要 */}
<div className="pt-3 flex flex-col gap-1 min-w-0">
<h2
className="j13-library-title text-[15px] font-bold leading-snug line-clamp-2"
style={{ color: "var(--ink)" }}
>
{doc.title}
</h2>
{doc.author ? (
<p className="meta flex items-center gap-1.5 text-[12px] min-w-0">
<PenLine size={11} className="shrink-0" style={{ color: "var(--accent)" }} aria-hidden />
<span className="truncate" title={doc.author}>
{doc.author}
</span>
</p>
) : null}
<div className="mt-1 flex items-center gap-1.5 min-w-0">
{exts.map((ext) => (
<ExtChip key={ext} ext={ext} />
))}
<span className="meta text-[11px] ml-auto inline-flex items-center gap-1 shrink-0">
<FileDown size={11} />
{doc.file_count > 0 ? `${doc.file_count} 个文件 · ${formatSize(totalSize)}` : "暂无文件"}
</span>
</div>
<div className="flex items-center gap-1.5 min-w-0">
{doc.creator ? (
<span className="flex items-center gap-1.5 min-w-0 shrink">
<Avatar size={16} name={creatorName} src={doc.creator.avatar} />
<span className="meta text-[11px] truncate max-w-[6rem]" title={creatorName}>
{creatorName}
</span>
</span>
) : null}
<span className="meta text-[11px] truncate shrink min-w-0">
{doc.creator ? "· " : ""}
{formatRelative(doc.created_at)}
</span>
</div>
</div>
</Link>
);
}
/**
* 书库目录:书架式竖版封面网格 + 书名/作者即时过滤(目录接口一次返回全部已发布
* 条目,纯客户端过滤;上限与后端 ListPublished 的 100 条一致)。
*/
export default function LibrarySearchGrid({
docs,
urlStyle,
}: {
docs: LibraryDocListItem[];
urlStyle: UrlStyle;
}) {
const [query, setQuery] = useState("");
const q = query.trim().toLowerCase();
const filtered = useMemo(() => {
if (!q) return docs;
return docs.filter(
(d) => d.title.toLowerCase().includes(q) || d.author.toLowerCase().includes(q)
);
}, [docs, q]);
if (docs.length === 0) {
return (
<div
className="mt-8 rounded-[24px] px-6 py-16 text-center"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<p className="text-[15px] font-medium" style={{ color: "var(--ink-2)" }}>
书库还没有内容
</p>
<p className="meta text-[13px] mt-2">管理员可在后台「书库管理」发布条目</p>
</div>
);
}
return (
<div className="mt-2">
{/* 搜索:居中宽框,与大气 Hero 呼应 */}
<div className="relative max-w-lg mx-auto">
<Search
size={16}
className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索书名或作者"
aria-label="搜索书名或作者"
className="j13-search-field w-full rounded-full pl-11 pr-10 py-3 text-[14px] shadow-[var(--shadow-sm)]"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除搜索"
className="absolute right-3 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-3)" }}
>
<X size={14} />
</button>
) : null}
</div>
{filtered.length === 0 ? (
<div
className="mt-8 rounded-[24px] px-6 py-16 text-center"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<p className="text-[14px] font-medium" style={{ color: "var(--ink-2)" }}>
没有找到与「{query.trim()}」相关的书籍
</p>
<p className="meta text-[12.5px] mt-2">换个书名或作者关键词试试</p>
</div>
) : (
<div className="mt-8 grid gap-x-5 gap-y-9 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
{filtered.map((d) => (
<DocCard key={d.id} doc={d} href={libraryDocPath(urlStyle, d.slug)} />
))}
</div>
)}
</div>
);
}