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

@@ -1,6 +1,6 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState, type ChangeEvent } from "react";
import {
BookOpen,
Download,
@@ -17,6 +17,7 @@ import {
apiAdminCreateLibraryDoc,
apiAdminDeleteLibraryDoc,
apiAdminDeleteLibraryFile,
apiAdminImportLibraryZip,
apiAdminListLibraryDocs,
apiAdminPurgeLibraryDoc,
apiAdminUpdateLibraryDoc,
@@ -25,17 +26,20 @@ import {
apiUploadPostImage,
type LibraryDoc,
type LibraryFileItem,
type LibraryImportReport,
type LibraryInput,
} from "@/lib/api";
import { formatDateTime, formatRelative } from "@/lib/format";
import { libraryDocPath } from "@/lib/urlStyle";
import { libraryDocPath, userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import Avatar from "@/components/Avatar";
import MarkdownEditor from "@/components/MarkdownEditor";
import { AdminEmpty, AdminField, AdminPageHeader, AdminStatusChip, AdminSwitch } from "@/components/admin";
import LibrarySectionsPanel from "./LibrarySectionsPanel";
import LibraryCoverPicker from "./LibraryCoverPicker";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
// 与后端 libraryAllowedExts 保持一致;前端先拦一道,后端仍会校验
const ALLOWED_EXTS = new Set([
@@ -59,9 +63,12 @@ function formatSize(n: number): string {
interface DocForm {
title: string;
author: string;
slug: string;
description: string;
cover_url: string;
cover_width: number;
cover_height: number;
published: boolean;
sort_order: number;
entries_auto: boolean;
@@ -69,9 +76,12 @@ interface DocForm {
const EMPTY_FORM: DocForm = {
title: "",
author: "",
slug: "",
description: "",
cover_url: "",
cover_width: 0,
cover_height: 0,
published: false,
sort_order: 0,
entries_auto: false,
@@ -80,9 +90,12 @@ const EMPTY_FORM: DocForm = {
function formOfDoc(d: LibraryDoc): DocForm {
return {
title: d.title,
author: d.author ?? "",
slug: d.slug,
description: d.description ?? "",
cover_url: d.cover_url ?? "",
cover_width: d.cover_width ?? 0,
cover_height: d.cover_height ?? 0,
published: d.published,
sort_order: d.sort_order,
entries_auto: d.entries_auto ?? false,
@@ -145,6 +158,13 @@ export default function LibraryAdmin({
const [fileDeleteTarget, setFileDeleteTarget] = useState<LibraryFileItem | null>(null);
const [fileDeleteBusy, setFileDeleteBusy] = useState(false);
// 书库导入恢复(上传导出 ZIP)
const importInputRef = useRef<HTMLInputElement>(null);
const [importing, setImporting] = useState(false);
const [importReport, setImportReport] = useState<LibraryImportReport | null>(null);
// create 模式遇到 conflict 时保留 File,供确认后 overwrite 重传
const [importOverwrite, setImportOverwrite] = useState<{ file: File; count: number } | null>(null);
// 条目删除(软删)与彻底删除
const [docDeleteTarget, setDocDeleteTarget] = useState<LibraryDoc | null>(null);
const [docDeleteBusy, setDocDeleteBusy] = useState(false);
@@ -170,6 +190,34 @@ export default function LibraryAdmin({
}
}
async function runImport(file: File, mode: "create" | "overwrite") {
setImporting(true);
setImportReport(null);
try {
const rep = await apiAdminImportLibraryZip(file, mode);
setImportReport(rep);
const okN = rep.imported.length;
const failN = rep.failed.length;
if (okN > 0) toast(`成功导入 ${okN} 本书${failN ? `,${failN} 本未导入` : ""}`, "ok");
else toast("没有书籍被导入,请查看失败明细", "error");
await reload();
if (mode === "create") {
const conflicts = rep.failed.filter((f) => f.reason === "conflict").length;
if (conflicts > 0) setImportOverwrite({ file, count: conflicts });
}
} catch (e) {
toast(e instanceof Error ? e.message : "导入失败");
} finally {
setImporting(false);
}
}
function onPickImportFile(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = ""; // 允许重选同一文件
if (file) void runImport(file, "create");
}
function openNew() {
setForm(EMPTY_FORM);
setSnapshot(JSON.stringify(EMPTY_FORM));
@@ -221,8 +269,11 @@ export default function LibraryAdmin({
const input: LibraryInput = {
slug,
title,
author: form.author.trim(),
description: form.description,
cover_url: form.cover_url.trim(),
cover_width: form.cover_width,
cover_height: form.cover_height,
published: form.published,
sort_order: form.sort_order,
entries_auto: form.entries_auto,
@@ -250,9 +301,13 @@ export default function LibraryAdmin({
if (!file) return;
setCoverBusy(true);
try {
const res = await apiUploadPostImage(file);
const res = await apiUploadPostImage(file, undefined, "library_cover");
if (res.url) {
patchForm({ cover_url: res.url });
patchForm({
cover_url: res.url,
cover_width: res.attachment?.width ?? 0,
cover_height: res.attachment?.height ?? 0,
});
} else {
setFormError(res.error || "封面上传失败");
}
@@ -267,8 +322,8 @@ export default function LibraryAdmin({
async function pickCoverFromMedia(attachmentId: number) {
setCoverBusy(true);
try {
const url = await apiLibraryCoverFromMedia(attachmentId);
patchForm({ cover_url: url });
const { url, width, height } = await apiLibraryCoverFromMedia(attachmentId);
patchForm({ cover_url: url, cover_width: width, cover_height: height });
} catch (e) {
setFormError(e instanceof Error ? e.message : "选用图库图片失败");
} finally {
@@ -374,17 +429,103 @@ export default function LibraryAdmin({
title="书库管理"
description="书籍、文档与页面作品。书籍由介绍、目录与章节正文组成,像书一样阅读;附件仅提供下载分发。"
actions={
<button
type="button"
className="btn btn-primary"
onClick={() => guardNav(openNew)}
disabled={saving}
>
<Plus size={15} /> 新建条目
</button>
<>
<a
href="/api/admin/library/export"
className="btn btn-ghost"
title="把全部书籍打包为 ZIP(介绍、章节、附件与封面),用于迁移或备份"
>
<Download size={15} aria-hidden />
导出全部
</a>
<button
type="button"
className="btn btn-ghost"
onClick={() => importInputRef.current?.click()}
disabled={importing}
title="上传导出的 ZIP 备份包,恢复书籍(介绍、章节、附件与封面)"
>
{importing ? (
<Loader2 size={15} className="animate-spin" aria-hidden />
) : (
<Upload size={15} aria-hidden />
)}
{importing ? "导入中…" : "导入恢复"}
</button>
<input
ref={importInputRef}
type="file"
accept=".zip,application/zip"
className="hidden"
onChange={onPickImportFile}
/>
<button
type="button"
className="btn btn-primary"
onClick={() => guardNav(openNew)}
disabled={saving}
>
<Plus size={15} /> 新建条目
</button>
</>
}
/>
{importReport ? (
<div
className="panel mx-3.5 sm:mx-4 my-3 px-4 py-3 text-[13px]"
role="status"
style={{ borderColor: "var(--line)" }}
>
<div className="flex items-center gap-2">
<strong style={{ color: "var(--ink)" }}>
导入完成:成功 {importReport.imported.length} 本
(新建 {importReport.imported.filter((i) => i.action === "created").length} / 覆盖{" "}
{importReport.imported.filter((i) => i.action === "overwritten").length}),失败{" "}
{importReport.failed.length} 本
</strong>
<button
type="button"
className="ml-auto btn btn-ghost"
style={{ padding: "2px 6px" }}
onClick={() => setImportReport(null)}
aria-label="关闭导入结果"
>
<X size={14} aria-hidden />
</button>
</div>
{importReport.failed.length > 0 ? (
<ul className="mt-2 flex flex-col gap-1">
{importReport.failed.map((f, i) => (
<li key={`${f.slug}-${i}`} className="meta flex flex-wrap items-baseline gap-x-2">
<span style={{ color: "var(--ink-2)" }}>
{f.title || f.slug}
{f.slug ? `(${f.slug})` : ""}
</span>
<span
style={{
color:
f.reason === "conflict" || f.reason === "deleted"
? "var(--warn, #b45309)"
: "var(--danger, #b91c1c)",
}}
>
{f.reason === "conflict"
? "同名书籍已存在,可覆盖导入"
: f.reason === "deleted"
? "地址被已删除条目占用,需先彻底删除"
: f.reason === "invalid"
? "备份数据不受支持"
: "导入失败"}
</span>
<span>{f.error}</span>
</li>
))}
</ul>
) : null}
</div>
) : null}
<div
className="admin-library-split"
data-view={editorOpen ? "editor" : "list"}
@@ -435,9 +576,11 @@ export default function LibraryAdmin({
}}
>
{d.cover_url ? (
// 36px 封面位:本站图走 48/96 变体
// eslint-disable-next-line @next/next/no-img-element
<img
src={d.cover_url}
src={isOwnImage(d.cover_url) ? variantURL(d.cover_url, pickVariantWidth(36)) : d.cover_url}
srcSet={isOwnImage(d.cover_url) ? densitySrcSet(d.cover_url, 36) : undefined}
alt=""
className="w-9 h-9 rounded-lg object-cover shrink-0"
style={{ border: "1px solid var(--line)" }}
@@ -560,6 +703,16 @@ export default function LibraryAdmin({
? `${editingDoc.sections?.length ?? 0} 章节 · ${editingDoc.files?.length ?? 0} 附件`
: ""}
</span>
{typeof editingId === "number" ? (
<a
href={`/api/admin/library/docs/${editingId}/export`}
className="btn btn-line btn-sm shrink-0"
title="导出本书为 ZIP(介绍、章节、附件与本地封面),用于迁移或备份"
>
<Download size={13} aria-hidden />
导出备份
</a>
) : null}
</div>
{/* 书籍信息 tab(两个 tab 均保持挂载,避免章节编辑状态丢失)。
@@ -586,6 +739,15 @@ export default function LibraryAdmin({
className="admin-doc-title"
/>
<AdminField
id="lib-author"
label="作者"
maxLength={100}
value={form.author}
onChange={(author) => patchForm({ author })}
placeholder="作者或编著者,多位可用逗号分隔(可选)"
/>
<div className="admin-doc-field">
<label className="admin-doc-label" htmlFor="lib-slug">
访问地址
@@ -628,6 +790,7 @@ export default function LibraryAdmin({
placeholder="条目介绍,支持 Markdown…"
rows={12}
ariaLabel="条目介绍"
uploadSource="library_content"
/>
</div>
</div>
@@ -636,56 +799,58 @@ export default function LibraryAdmin({
<aside className="flex flex-col gap-4 lg:min-h-0">
<div className="admin-doc-field">
<p className="admin-doc-label">封面(可选)</p>
<div className="flex items-center gap-2.5">
<div className="flex flex-col gap-2">
{form.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.cover_url}
src={isOwnImage(form.cover_url) ? variantURL(form.cover_url, 256) : form.cover_url}
alt="封面预览"
className="w-12 h-12 rounded-lg object-cover shrink-0"
className="w-full h-32 rounded-xl object-cover"
style={{ border: "1px solid var(--line)" }}
/>
) : (
<button
type="button"
className="w-12 h-12 rounded-lg flex items-center justify-center shrink-0"
className="w-full h-32 rounded-xl flex flex-col items-center justify-center gap-1.5"
style={{ border: "1px dashed var(--control-border)" }}
aria-label="上传封面"
onClick={() => coverInputRef.current?.click()}
>
<ImagePlus size={16} style={{ color: "var(--ink-3)" }} aria-hidden />
<ImagePlus size={20} style={{ color: "var(--ink-3)" }} aria-hidden />
<span className="text-[11.5px]" style={{ color: "var(--ink-3)" }}>
点击上传封面
</span>
</button>
)}
<div className="flex flex-col gap-1">
<div className="flex gap-1.5">
<button
type="button"
className="btn btn-line btn-sm flex-1"
disabled={coverBusy}
onClick={() => coverInputRef.current?.click()}
>
{coverBusy ? (
<Loader2 size={13} className="animate-spin" aria-hidden />
) : (
<ImagePlus size={13} aria-hidden />
)}
{form.cover_url ? "更换" : "上传"}
</button>
<button
type="button"
className="btn btn-line btn-sm flex-1"
disabled={coverBusy}
onClick={() => setCoverPickerOpen(true)}
>
<Images size={13} aria-hidden />
图库
</button>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
className="btn btn-line btn-sm"
disabled={coverBusy}
onClick={() => coverInputRef.current?.click()}
>
{coverBusy ? (
<Loader2 size={13} className="animate-spin" aria-hidden />
) : (
<ImagePlus size={13} aria-hidden />
)}
{form.cover_url ? "更换" : "上传"}
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={coverBusy}
onClick={() => setCoverPickerOpen(true)}
>
<Images size={13} aria-hidden />
图库
</button>
{form.cover_url ? (
<button
type="button"
className="admin-doc-textbtn"
onClick={() => patchForm({ cover_url: "" })}
className="admin-doc-textbtn ml-auto"
style={{ minHeight: 0, minWidth: 0, padding: "1px 6px", fontSize: 12 }}
onClick={() => patchForm({ cover_url: "", cover_width: 0, cover_height: 0 })}
>
移除
</button>
@@ -749,6 +914,30 @@ export default function LibraryAdmin({
<p className="admin-doc-hint">数字越小越靠前。</p>
</div>
{typeof editingId === "number" && editingDoc?.creator ? (
<div className="admin-doc-field">
<p className="admin-doc-label">创建者</p>
<a
href={userPath(urlStyle, editingDoc.creator.id)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-[13px] font-medium max-w-full"
style={{ color: "var(--ink)" }}
title={`@${editingDoc.creator.username}`}
>
<Avatar
size={22}
name={editingDoc.creator.nickname || editingDoc.creator.username}
src={editingDoc.creator.avatar}
/>
<span className="truncate">
{editingDoc.creator.nickname || editingDoc.creator.username}
</span>
</a>
<p className="admin-doc-hint">条目创建后归属不变。</p>
</div>
) : null}
{/* 附件管理:长列表列内滚动 */}
<div className="admin-doc-field flex flex-col lg:flex-1 lg:min-h-0">
<p className="admin-doc-label shrink-0">
@@ -956,6 +1145,27 @@ export default function LibraryAdmin({
</section>
</div>
{/* 导入遇到同名书籍:确认后覆盖重传(保留同一 File) */}
<ConfirmDialog
open={!!importOverwrite}
title="覆盖导入同名书籍?"
message={
importOverwrite
? `备份包中有 ${importOverwrite.count} 本书的地址已被现有书籍占用。选择覆盖将用备份包整体替换这些书籍的介绍、章节、附件与封面,现有内容不可恢复;其余书籍正常新建。`
: ""
}
danger
busy={importing}
confirmLabel="覆盖导入"
cancelLabel="保留现有"
onCancel={() => !importing && setImportOverwrite(null)}
onConfirm={() => {
const pending = importOverwrite;
setImportOverwrite(null);
if (pending) void runImport(pending.file, "overwrite");
}}
/>
{/* 未保存关闭/切换确认 */}
<ConfirmDialog
open={!!pendingNav}

View File

@@ -4,8 +4,35 @@ import { Images, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
/** 书籍封面图库选择弹窗:列出本人媒体库图片,选中后由父组件复制一份作封面素材 */
/** 来源标签映射 */
const SOURCE_LABELS: Record<string, string> = {
library_cover: "书籍封面",
library_content: "书籍内容",
post: "帖子插图",
};
function sourceLabel(source?: string): string {
return SOURCE_LABELS[source || "post"] || source || "帖子插图";
}
/** 按来源分组(library_cover 优先,其次 library_content,最后 post/其他) */
function groupBySource(items: MediaAttachment[]): [string, MediaAttachment[]][] {
const groups = new Map<string, MediaAttachment[]>();
for (const item of items) {
const key = item.source || "post";
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(item);
}
// 按优先级排序
const order = ["library_cover", "library_content", "post"];
return Array.from(groups.entries()).sort(
([a], [b]) => order.indexOf(a) - order.indexOf(b)
);
}
/** 书籍封面图库选择弹窗:列出本人媒体库图片,按来源分组 */
export default function LibraryCoverPicker({
open,
onClose,
@@ -45,13 +72,15 @@ export default function LibraryCoverPicker({
};
}, [open]);
const groups = groupBySource(items);
return (
<Modal
open={open}
title="从图库选择封面"
description="选择后会复制一份作为封面素材,之后删除原图不影响封面。"
description="已选作封面的图片会单独分组,再次选择直接复用。"
onClose={onClose}
maxWidthClass="max-w-lg"
maxWidthClass="max-w-2xl"
dismissOnOverlay
>
{loading ? (
@@ -67,28 +96,52 @@ export default function LibraryCoverPicker({
图库还没有图片。上传封面或发帖插图后,会自动出现在这里。
</p>
) : (
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2 max-h-[46vh] overflow-y-auto">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onPick(item);
onClose();
}}
className="relative group aspect-square rounded-xl overflow-hidden"
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
data-tip="复制一份用作封面"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.url} alt="图库候选封面" className="w-full h-full object-contain" loading="lazy" />
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
<div className="flex flex-col gap-5 max-h-[60vh] overflow-y-auto">
{groups.map(([source, groupItems]) => (
<section key={source}>
<h4
className="text-[11.5px] font-bold tracking-wide uppercase mb-2 px-0.5"
style={{ color: "var(--ink-3)" }}
>
<Images size={14} />
</span>
</button>
{sourceLabel(source)}
<span className="ml-1.5 font-normal opacity-70">({groupItems.length})</span>
</h4>
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
{groupItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onPick(item);
onClose();
}}
className="relative group aspect-square rounded-xl overflow-hidden"
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
data-tip={
item.source === "library_cover"
? "已作封面,点击直接复用"
: "复制一份用作封面"
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
sizes="(max-width:520px) 23vw, 96px"
alt="图库候选封面"
className="w-full h-full object-contain"
loading="lazy"
/>
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
>
<Images size={14} />
</span>
</button>
))}
</div>
</section>
))}
</div>
)}

View File

@@ -410,6 +410,7 @@ export default function LibrarySectionsPanel({
placeholder="本章正文,支持 Markdown…"
rows={16}
ariaLabel="章节正文"
uploadSource="library_content"
/>
</div>
</div>

View File

@@ -5,7 +5,7 @@ import LibraryAdmin from "./LibraryAdmin";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminLibraryDocs, type LibraryDoc } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
export default async function AdminLibraryPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.LIBRARY)) {
return <Forbidden text="书库管理仅管理员及以上角色可操作。" />;
}