新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
507 lines
17 KiB
TypeScript
507 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||
import {
|
||
ChevronDown,
|
||
ChevronUp,
|
||
CornerDownRight,
|
||
ListTree,
|
||
Loader2,
|
||
Plus,
|
||
Save,
|
||
Trash2,
|
||
Upload,
|
||
} from "lucide-react";
|
||
import {
|
||
apiAdminCreateLibrarySection,
|
||
apiAdminDeleteLibrarySection,
|
||
apiAdminImportLibraryMarkdownBatch,
|
||
apiAdminMoveLibrarySection,
|
||
apiAdminUpdateLibrarySection,
|
||
type LibraryDoc,
|
||
type LibraryImportMdInput,
|
||
type LibrarySectionItem,
|
||
} from "@/lib/api";
|
||
import { toast } from "@/lib/toast";
|
||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||
import { AdminEmpty } from "@/components/admin";
|
||
import ImportMdDialog from "./ImportMdDialog";
|
||
|
||
const MAX_TITLE = 200;
|
||
const MAX_CONTENT = 100_000; // 与后端 MaxSectionContent 保持一致
|
||
|
||
const EMPTY_SNAPSHOT = JSON.stringify({ title: "", content: "" });
|
||
|
||
/**
|
||
* 「章节与正文」面板:左章节树(章 → 节 两级),右章节编辑器。
|
||
* 数据从 doc.sections 派生(后端 detail 已带全量含 content);
|
||
* 所有变更后调 onChanged() 让父组件刷新列表,doc.sections 随之更新。
|
||
*/
|
||
export default function LibrarySectionsPanel({
|
||
doc,
|
||
onChanged,
|
||
onDirtyChange,
|
||
}: {
|
||
doc: LibraryDoc;
|
||
/** 数据变更后由父组件刷新(doc.sections 随之更新) */
|
||
onChanged: () => Promise<void>;
|
||
/** dirty 状态上报,供父组件在关闭编辑器 / 切换条目前确认 */
|
||
onDirtyChange: (dirty: boolean) => void;
|
||
}) {
|
||
const sections = doc.sections ?? [];
|
||
|
||
// 平铺 → 章分组(顺序沿用后端 sort_order)
|
||
const groups = useMemo(() => {
|
||
const gs: { chapter: LibrarySectionItem; subs: LibrarySectionItem[] }[] = [];
|
||
for (const s of sections) {
|
||
if (s.parent_id == null) gs.push({ chapter: s, subs: [] });
|
||
else gs[gs.length - 1]?.subs.push(s);
|
||
}
|
||
return gs;
|
||
}, [sections]);
|
||
|
||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||
const [title, setTitle] = useState("");
|
||
const [content, setContent] = useState("");
|
||
const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [pendingNav, setPendingNav] = useState<(() => void) | null>(null);
|
||
const [deleteTarget, setDeleteTarget] = useState<LibrarySectionItem | null>(null);
|
||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||
const [importOpen, setImportOpen] = useState(false);
|
||
// 新建 / 导入后要选中的章节 id:等 doc.sections 里出现再应用,避免被旧数据清掉
|
||
const [pendingSelectId, setPendingSelectId] = useState<number | null>(null);
|
||
|
||
const selected = useMemo(
|
||
() => sections.find((s) => s.id === selectedId) ?? null,
|
||
[sections, selectedId]
|
||
);
|
||
const dirty = JSON.stringify({ title, content }) !== snapshot;
|
||
|
||
// dirty 上报给父组件;卸载时归零,避免残留的 dirty 影响父组件的关闭确认
|
||
useEffect(() => {
|
||
onDirtyChange(dirty);
|
||
return () => onDirtyChange(false);
|
||
}, [dirty, onDirtyChange]);
|
||
|
||
// 选中项被删除后清空选中
|
||
useEffect(() => {
|
||
if (selectedId != null && !sections.some((s) => s.id === selectedId)) {
|
||
setSelectedId(null);
|
||
}
|
||
}, [sections, selectedId]);
|
||
|
||
// 应用待选章节(新建 / 导入完成后)
|
||
useEffect(() => {
|
||
if (pendingSelectId == null) return;
|
||
const s = sections.find((x) => x.id === pendingSelectId);
|
||
if (!s) return;
|
||
setPendingSelectId(null);
|
||
setSelectedId(s.id);
|
||
setTitle(s.title);
|
||
setContent(s.content);
|
||
setSnapshot(JSON.stringify({ title: s.title, content: s.content }));
|
||
setError("");
|
||
}, [sections, pendingSelectId]);
|
||
|
||
function loadSection(s: LibrarySectionItem) {
|
||
setSelectedId(s.id);
|
||
setTitle(s.title);
|
||
setContent(s.content);
|
||
setSnapshot(JSON.stringify({ title: s.title, content: s.content }));
|
||
setError("");
|
||
}
|
||
|
||
/** dirty 时先弹确认,再执行动作(切换章节 / 打开导入 / 新增章节) */
|
||
function guardNav(action: () => void) {
|
||
if (dirty && !busy) setPendingNav(() => action);
|
||
else action();
|
||
}
|
||
|
||
function selectSection(s: LibrarySectionItem) {
|
||
if (s.id === selectedId) return;
|
||
guardNav(() => loadSection(s));
|
||
}
|
||
|
||
async function addSection(parent: LibrarySectionItem | null) {
|
||
setBusy(true);
|
||
setError("");
|
||
try {
|
||
const { section } = await apiAdminCreateLibrarySection(doc.id, {
|
||
parent_id: parent?.id,
|
||
title: parent ? "新节" : "新章",
|
||
});
|
||
await onChanged();
|
||
setPendingSelectId(section.id);
|
||
toast(parent ? "小节已添加" : "章已添加", "ok");
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "添加章节失败");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function save() {
|
||
if (selectedId == null) return;
|
||
const t = title.trim();
|
||
if (!t) {
|
||
setError("章节标题不能为空");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setError("");
|
||
try {
|
||
const { section } = await apiAdminUpdateLibrarySection(selectedId, {
|
||
title: t,
|
||
content,
|
||
});
|
||
await onChanged();
|
||
// 以服务端返回为准(后端会 trim 标题),保证 dirty 归零
|
||
setTitle(section.title);
|
||
setContent(section.content);
|
||
setSnapshot(JSON.stringify({ title: section.title, content: section.content }));
|
||
toast("章节已保存", "ok");
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "保存章节失败");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function move(s: LibrarySectionItem, direction: "up" | "down") {
|
||
setBusy(true);
|
||
setError("");
|
||
try {
|
||
await apiAdminMoveLibrarySection(s.id, direction);
|
||
await onChanged(); // 选中 id 不变,原地刷新顺序
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "移动章节失败");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function removeSection() {
|
||
if (!deleteTarget) return;
|
||
const target = deleteTarget;
|
||
setDeleteBusy(true);
|
||
try {
|
||
await apiAdminDeleteLibrarySection(target.id);
|
||
toast(target.parent_id == null ? "章已删除" : "小节已删除", "ok");
|
||
setDeleteTarget(null);
|
||
await onChanged();
|
||
if (target.id === selectedId) {
|
||
setSelectedId(null);
|
||
setTitle("");
|
||
setContent("");
|
||
setSnapshot(EMPTY_SNAPSHOT);
|
||
setError("");
|
||
}
|
||
} catch (e) {
|
||
toast(e instanceof Error ? e.message : "删除章节失败");
|
||
} finally {
|
||
setDeleteBusy(false);
|
||
}
|
||
}
|
||
|
||
async function handleImport(input: LibraryImportMdInput) {
|
||
const res = await apiAdminImportLibraryMarkdownBatch(doc.id, input);
|
||
await onChanged();
|
||
return res.sections;
|
||
}
|
||
|
||
function closeImport(created?: LibrarySectionItem[]) {
|
||
setImportOpen(false);
|
||
if (created && created.length > 0) setPendingSelectId(created[0].id);
|
||
}
|
||
|
||
/** 该章节在同级兄弟中的位置(供上移/下移禁用判断) */
|
||
function siblingPos(s: LibrarySectionItem): { first: boolean; last: boolean } {
|
||
if (s.parent_id == null) {
|
||
const i = groups.findIndex((g) => g.chapter.id === s.id);
|
||
return { first: i <= 0, last: i < 0 || i >= groups.length - 1 };
|
||
}
|
||
const g = groups.find((x) => x.subs.some((y) => y.id === s.id));
|
||
const subs = g?.subs ?? [];
|
||
const i = subs.findIndex((y) => y.id === s.id);
|
||
return { first: i <= 0, last: i < 0 || i >= subs.length - 1 };
|
||
}
|
||
|
||
function RowAction({
|
||
label,
|
||
onClick,
|
||
disabled,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
onClick: () => void;
|
||
disabled?: boolean;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="w-6 h-6 shrink-0 rounded-md flex items-center justify-center hover:bg-[var(--panel)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||
style={{ color: "var(--ink-3)" }}
|
||
title={label}
|
||
aria-label={label}
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function row(s: LibrarySectionItem, depth: 0 | 1, isFirst: boolean, isLast: boolean) {
|
||
const active = s.id === selectedId;
|
||
const pos = siblingPos(s);
|
||
return (
|
||
<div
|
||
key={s.id}
|
||
className="flex items-center gap-0.5 pr-1.5"
|
||
style={{
|
||
paddingLeft: depth === 0 ? 4 : 22,
|
||
background: active ? "var(--accent-soft)" : undefined,
|
||
boxShadow: active ? "inset 2px 0 0 var(--accent)" : undefined,
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => selectSection(s)}
|
||
aria-current={active ? "true" : undefined}
|
||
className="min-w-0 flex-1 text-left px-2 py-2 flex items-center gap-1 rounded-md transition-colors hover:bg-[var(--panel-2)]"
|
||
title={s.title}
|
||
>
|
||
{depth === 1 ? (
|
||
<CornerDownRight
|
||
size={12}
|
||
className="shrink-0"
|
||
style={{ color: "var(--ink-3)" }}
|
||
aria-hidden
|
||
/>
|
||
) : null}
|
||
<span
|
||
className="truncate text-[13px]"
|
||
style={{
|
||
color: active ? "var(--accent)" : "var(--ink)",
|
||
fontWeight: depth === 0 ? 600 : 400,
|
||
}}
|
||
>
|
||
{s.title || "未命名章节"}
|
||
</span>
|
||
</button>
|
||
{active ? (
|
||
<>
|
||
<RowAction label="上移" disabled={pos.first || busy} onClick={() => void move(s, "up")}>
|
||
<ChevronUp size={13} aria-hidden />
|
||
</RowAction>
|
||
<RowAction label="下移" disabled={pos.last || busy} onClick={() => void move(s, "down")}>
|
||
<ChevronDown size={13} aria-hidden />
|
||
</RowAction>
|
||
{depth === 0 ? (
|
||
<RowAction
|
||
label="添加小节"
|
||
disabled={busy}
|
||
onClick={() => void addSection(s)}
|
||
>
|
||
<CornerDownRight size={13} aria-hidden />
|
||
</RowAction>
|
||
) : null}
|
||
<RowAction label="删除" onClick={() => setDeleteTarget(s)}>
|
||
<Trash2 size={13} aria-hidden />
|
||
</RowAction>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full min-h-0">
|
||
{/* 左:章节树 */}
|
||
<aside
|
||
className="w-52 sm:w-60 shrink-0 flex flex-col min-h-0"
|
||
style={{ borderRight: "1px solid var(--line)" }}
|
||
aria-label="章节树"
|
||
>
|
||
<div
|
||
className="shrink-0 flex items-center gap-1.5 px-2.5 py-2.5 flex-wrap"
|
||
style={{ borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="btn btn-line btn-sm"
|
||
disabled={busy}
|
||
onClick={() => guardNav(() => void addSection(null))}
|
||
>
|
||
<Plus size={13} aria-hidden /> 添加章
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-line btn-sm"
|
||
disabled={busy}
|
||
onClick={() => guardNav(() => setImportOpen(true))}
|
||
>
|
||
<Upload size={13} aria-hidden /> 导入
|
||
</button>
|
||
</div>
|
||
<div className="flex-1 min-h-0 overflow-y-auto j13-rail-scroll py-1.5">
|
||
{groups.length === 0 ? (
|
||
<p className="meta text-[12.5px] px-3 py-3 leading-relaxed">
|
||
还没有章节。点上方「添加章」手动编写,或「导入」批量导入 .md / .epub(epub 按目录顺序拆章)。
|
||
</p>
|
||
) : (
|
||
groups.map((g, gi) => (
|
||
<div key={g.chapter.id}>
|
||
{row(g.chapter, 0, gi === 0, gi === groups.length - 1)}
|
||
{g.subs.map((sub, si) => row(sub, 1, si === 0, si === g.subs.length - 1))}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
<div
|
||
className="shrink-0 px-3 py-2 meta text-[11.5px] tabular-nums"
|
||
style={{ borderTop: "1px solid var(--line)" }}
|
||
>
|
||
{sections.length} 章节
|
||
</div>
|
||
</aside>
|
||
|
||
{/* 右:章节编辑器 */}
|
||
<div className="flex-1 min-w-0 flex flex-col min-h-0">
|
||
{selected ? (
|
||
<>
|
||
<div className="flex-1 min-h-0 overflow-y-auto j13-rail-scroll px-4 sm:px-5 py-4 flex flex-col gap-4">
|
||
<div className="admin-doc-field">
|
||
<label className="admin-doc-label" htmlFor="lib-section-title">
|
||
章节标题
|
||
</label>
|
||
<input
|
||
id="lib-section-title"
|
||
className="admin-field-input"
|
||
maxLength={MAX_TITLE}
|
||
value={title}
|
||
onChange={(e) => {
|
||
setError("");
|
||
setTitle(e.target.value);
|
||
}}
|
||
placeholder="例如:第一章 …"
|
||
disabled={busy}
|
||
/>
|
||
</div>
|
||
{/* flex-1 撑满滚动区剩余高度:编辑框最大化、提示行紧贴底部操作条上方 */}
|
||
<div className="admin-doc-md flex flex-col flex-1 min-h-0">
|
||
<label className="admin-doc-label" htmlFor="lib-section-content">
|
||
正文(Markdown)
|
||
</label>
|
||
<MarkdownEditor
|
||
id="lib-section-content"
|
||
value={content}
|
||
onChange={(v) => {
|
||
setError("");
|
||
setContent(v.slice(0, MAX_CONTENT));
|
||
}}
|
||
maxChars={MAX_CONTENT}
|
||
allowHide={false}
|
||
entriesAuto={doc.entries_auto ?? false}
|
||
placeholder="本章正文,支持 Markdown…"
|
||
rows={16}
|
||
ariaLabel="章节正文"
|
||
uploadSource="library_content"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 底部操作条:不随正文滚动 */}
|
||
<footer
|
||
className="shrink-0 flex items-center gap-2 px-3.5 sm:px-4 py-3"
|
||
style={{ borderTop: "1px solid var(--line)", background: "var(--panel-2)" }}
|
||
>
|
||
<div className="mr-auto flex items-center gap-3 min-w-0">
|
||
{error ? (
|
||
<p className="admin-settings-row-error" role="alert">
|
||
{error}
|
||
</p>
|
||
) : dirty ? (
|
||
<span className="meta text-[12px]">有未保存的修改</span>
|
||
) : null}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-line btn-sm"
|
||
disabled={busy || deleteBusy}
|
||
onClick={() => setDeleteTarget(selected)}
|
||
>
|
||
<Trash2 size={13} aria-hidden /> 删除章节
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary btn-sm"
|
||
disabled={busy || !dirty}
|
||
title={dirty ? undefined : "没有需要保存的修改"}
|
||
onClick={() => void save()}
|
||
aria-busy={busy || undefined}
|
||
>
|
||
{busy ? (
|
||
<Loader2 size={13} className="animate-spin" aria-hidden />
|
||
) : (
|
||
<Save size={13} aria-hidden />
|
||
)}
|
||
{busy ? "保存中…" : "保存章节"}
|
||
</button>
|
||
</footer>
|
||
</>
|
||
) : (
|
||
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
|
||
<AdminEmpty
|
||
icon={<ListTree size={22} />}
|
||
title="未选择章节"
|
||
description="从左侧选择章节编辑,或添加章 / 导入 Markdown"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 未保存切换章节确认 */}
|
||
<ConfirmDialog
|
||
open={!!pendingNav}
|
||
title="放弃未保存的修改?"
|
||
message="当前章节有未保存的修改,继续将丢失这些改动。"
|
||
danger
|
||
confirmLabel="放弃修改"
|
||
cancelLabel="继续编辑"
|
||
onCancel={() => setPendingNav(null)}
|
||
onConfirm={() => {
|
||
const action = pendingNav;
|
||
setPendingNav(null);
|
||
action?.();
|
||
}}
|
||
/>
|
||
|
||
{/* 删除章节确认 */}
|
||
<ConfirmDialog
|
||
open={!!deleteTarget}
|
||
title={deleteTarget?.parent_id == null ? "删除章" : "删除小节"}
|
||
message={
|
||
deleteTarget
|
||
? `确定删除「${deleteTarget.title}」吗?${
|
||
deleteTarget.parent_id == null ? "其下所有小节将一并删除," : ""
|
||
}且不可恢复。`
|
||
: ""
|
||
}
|
||
danger
|
||
busy={deleteBusy}
|
||
confirmLabel="删除"
|
||
onCancel={() => !deleteBusy && setDeleteTarget(null)}
|
||
onConfirm={() => void removeSection()}
|
||
/>
|
||
|
||
{/* 导入 Markdown 拆章 */}
|
||
{importOpen ? <ImportMdDialog onImport={handleImport} onClose={closeImport} /> : null}
|
||
</div>
|
||
);
|
||
}
|