Files
ttstd-infinite-canvas/web/src/pages/projects/detail/chapters.tsx
T

555 lines
43 KiB
TypeScript

import { useDeferredValue, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { App, Button, Dropdown, Form, Input, InputNumber, Modal, Popconfirm, Tooltip } from "antd";
import CharacterCount from "@tiptap/extension-character-count";
import Color from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import Placeholder from "@tiptap/extension-placeholder";
import TextAlign from "@tiptap/extension-text-align";
import { TextStyle } from "@tiptap/extension-text-style";
import { EditorContent, useEditor, type Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Bold,
Check,
ChevronDown,
Clapperboard,
Code2,
Crosshair,
Eraser,
FileUp,
GripVertical,
Highlighter,
Italic,
Link2,
List,
ListOrdered,
Minus,
MoreHorizontal,
MoveVertical,
Plus,
Quote,
Redo2,
Save,
Search,
Strikethrough,
Trash2,
Underline,
Undo2,
UsersRound,
X,
} from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspace-state";
import { canvasStylePresets } from "@/components/canvas/canvas-style-picker-modal";
import { normalizeCharacterName } from "@/lib/canvas/canvas-character-reference";
import { decodeNovelText, splitTextIntoChapters } from "@/lib/canvas/canvas-document";
import { navigateToSettings } from "@/lib/settings-navigation";
import {
createProjectAssetCandidates,
createProjectUnit,
deleteProjectUnit,
getProjectUnit,
importProjectUnits,
reorderProjectUnits,
replaceProjectUnitShots,
updateProjectUnit,
type ProjectUnit,
} from "@/services/api/projects";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { formatCount, formatTime, statusLabel, type ProjectDetailViewProps } from "./shared";
import { extractChapterCharacters, generateChapterStoryboard } from "./project-chapter-ai";
const CHAPTER_ROW_HEIGHT = 52;
const MAX_NOVEL_IMPORT_CHAPTERS = 2500;
export default function ProjectChaptersView({ detail, refreshProject }: ProjectDetailViewProps) {
const { message, modal } = App.useApp();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { chapterId = "" } = useParams();
const initialSelectedId = detail.units.some((unit) => unit.id === chapterId) ? chapterId : detail.units[0]?.id || "";
const [selectedId, setSelectedId] = useState(initialSelectedId);
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [moveTargetId, setMoveTargetId] = useState("");
const [movePosition, setMovePosition] = useState<number | null>(null);
const [orderedIds, setOrderedIds] = useState(() => detail.units.map((unit) => unit.id));
const [draggedId, setDraggedId] = useState("");
const [draftTitle, setDraftTitle] = useState("");
const [draftHtml, setDraftHtml] = useState("");
const [dirty, setDirty] = useState(false);
const [analysisKind, setAnalysisKind] = useState<"characters" | "storyboard" | "">("");
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const listRef = useRef<HTMLDivElement>(null);
const deferredSearchQuery = useDeferredValue(searchQuery.trim().toLocaleLowerCase("zh-CN"));
const orderedUnits = useMemo(() => {
const byId = new Map(detail.units.map((unit) => [unit.id, unit]));
return orderedIds.map((id) => byId.get(id)).filter((unit): unit is ProjectUnit => Boolean(unit));
}, [detail.units, orderedIds]);
const selectedUnitSummary = detail.units.find((unit) => unit.id === selectedId) || orderedUnits[0];
const selectedUnitQuery = useQuery({
queryKey: ["project-unit", detail.project.id, selectedId],
queryFn: () => getProjectUnit(detail.project.id, selectedId),
enabled: Boolean(selectedId),
});
const selectedUnit = selectedUnitQuery.data?.unit || selectedUnitSummary;
const chapterNumberById = useMemo(() => new Map(orderedUnits.map((unit, index) => [unit.id, index + 1])), [orderedUnits]);
const canvasCountByUnitId = useMemo(() => detail.canvasUnitLinks.reduce<Map<string, number>>((result, link) => result.set(link.unitId, (result.get(link.unitId) || 0) + 1), new Map()), [detail.canvasUnitLinks]);
const visibleUnits = useMemo(() => {
if (!deferredSearchQuery) return orderedUnits;
const numericQuery = /^\d+$/.test(deferredSearchQuery) ? deferredSearchQuery.replace(/^0+/, "") || "0" : "";
return orderedUnits.filter((unit, index) => (numericQuery && String(index + 1).startsWith(numericQuery)) || unit.title.toLocaleLowerCase("zh-CN").includes(deferredSearchQuery));
}, [deferredSearchQuery, orderedUnits]);
const chapterVirtualizer = useVirtualizer({
count: visibleUnits.length,
getScrollElement: () => listRef.current,
estimateSize: () => CHAPTER_ROW_HEIGHT,
getItemKey: (index) => visibleUnits[index]?.id || index,
initialOffset: () => readStoredScroll(`project-chapters:${detail.project.id}`),
onChange: (instance, scrolling) => {
if (!scrolling && instance.scrollOffset !== null) sessionStorage.setItem(`project-chapters:${detail.project.id}`, String(instance.scrollOffset));
},
overscan: 10,
});
useEffect(() => {
setOrderedIds(detail.units.slice().sort((left, right) => left.position - right.position).map((unit) => unit.id));
if (!detail.units.some((unit) => unit.id === selectedId)) setSelectedId(detail.units[0]?.id || "");
}, [detail.units, selectedId]);
useEffect(() => {
if (!chapterId || chapterId === selectedId || dirty || !detail.units.some((unit) => unit.id === chapterId)) return;
setSelectedId(chapterId);
}, [chapterId, detail.units, dirty, selectedId]);
useEffect(() => {
if (!chapterId || dirty || detail.units.some((unit) => unit.id === chapterId)) return;
const firstId = orderedUnits[0]?.id;
navigate(firstId ? `/projects/${detail.project.id}/chapters/${firstId}` : `/projects/${detail.project.id}/chapters`, { replace: true });
}, [chapterId, detail.project.id, detail.units, dirty, navigate, orderedUnits]);
useEffect(() => {
if (selectedId) sessionStorage.setItem(`project-active-chapter:${detail.project.id}`, selectedId);
}, [detail.project.id, selectedId]);
const saveMutation = useMutation({
mutationFn: () => selectedUnitQuery.data?.unit
? updateProjectUnit(detail.project.id, selectedUnitQuery.data.unit.id, {
title: draftTitle.trim(),
sourceText: draftHtml,
status: stripHtml(draftHtml) ? "ready" : "draft",
})
: Promise.reject(new Error("请选择章节")),
onSuccess: ({ unit }) => { queryClient.setQueryData(["project-unit", detail.project.id, unit.id], { unit }); setDirty(false); refreshProject(); message.success("章节已保存"); },
onError: (error) => message.error(error instanceof Error ? error.message : "章节保存失败"),
});
const createMutation = useMutation({
mutationFn: (values: { title: string; sourceText?: string }) => createProjectUnit(detail.project.id, { kind: "chapter", title: values.title, sourceText: plainTextToHtml(values.sourceText || ""), position: detail.units.length }),
onSuccess: ({ unit }) => { setCreateOpen(false); setSelectedId(unit.id); refreshProject(); navigate(`/projects/${detail.project.id}/chapters/${unit.id}`); message.success("章节已创建"); },
onError: (error) => message.error(error instanceof Error ? error.message : "章节创建失败"),
});
const importMutation = useMutation({
mutationFn: (chapters: Array<{ title: string; plainText: string }>) => importProjectUnits(detail.project.id, chapters.map((chapter) => ({ kind: "chapter", title: chapter.title, sourceText: plainTextToHtml(chapter.plainText) }))),
onSuccess: ({ units }) => { setImportOpen(false); if (units[0]) { setSelectedId(units[0].id); navigate(`/projects/${detail.project.id}/chapters/${units[0].id}`); } refreshProject(); message.success(`已导入 ${units.length} 章`); },
onError: (error) => message.error(error instanceof Error ? error.message : "小说导入失败"),
});
const reorderMutation = useMutation({
mutationFn: (unitIds: string[]) => reorderProjectUnits(detail.project.id, unitIds),
onSuccess: () => { refreshProject(); message.success("章节顺序已更新"); },
onError: (error) => { refreshProject(); message.error(error instanceof Error ? error.message : "章节排序失败"); },
});
const deleteMutation = useMutation({
mutationFn: (unitId: string) => deleteProjectUnit(detail.project.id, unitId),
onSuccess: (_, unitId) => {
const index = orderedIds.indexOf(unitId);
const remaining = orderedIds.filter((id) => id !== unitId);
setOrderedIds(remaining);
if (selectedId === unitId) {
const nextId = remaining[Math.min(index, remaining.length - 1)] || "";
setSelectedId(nextId);
navigate(nextId ? `/projects/${detail.project.id}/chapters/${nextId}` : `/projects/${detail.project.id}/chapters`, { replace: true });
}
refreshProject();
message.success("章节已删除");
},
onError: (error) => message.error(error instanceof Error ? error.message : "章节删除失败"),
});
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: { openOnClick: false, autolink: true } }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
TextStyle,
Color.configure({ types: ["textStyle"] }),
Highlight.configure({ multicolor: true }),
CharacterCount,
Placeholder.configure({ placeholder: "从这一章开始写下故事……" }),
],
content: selectedUnitQuery.data?.unit.sourceText || "",
editorProps: { attributes: { class: "project-chapter-editor focus:outline-none" } },
onUpdate: ({ editor: nextEditor }) => { setDraftHtml(nextEditor.getHTML()); setDirty(true); },
});
useEffect(() => {
if (!selectedUnitSummary) return;
setDraftTitle(selectedUnitSummary.title);
setDraftHtml("");
setDirty(false);
}, [selectedUnitSummary?.id]);
useEffect(() => {
const loadedUnit = selectedUnitQuery.data?.unit;
if (!loadedUnit || !editor) return;
setDraftTitle(loadedUnit.title);
setDraftHtml(loadedUnit.sourceText || "");
setDirty(false);
editor.commands.setContent(loadedUnit.sourceText || "", { emitUpdate: false });
// 只在切换章节时装载服务端内容,避免项目刷新覆盖当前未保存正文。
}, [editor, selectedUnitQuery.data?.unit]);
const wordCount = useMemo(() => editor?.storage.characterCount?.characters?.() || stripHtml(draftHtml).length || 0, [draftHtml, editor]);
const chapterCanvasCount = (unitId: string) => canvasCountByUnitId.get(unitId) || 0;
const chapterShotCount = (unitId: string) => detail.shots.filter((shot) => shot.unitId === unitId).length;
const chapterAnalysisInput = () => {
const unit = selectedUnitQuery.data?.unit;
if (!unit) throw new Error("章节正文尚未加载完成");
if (dirty) throw new Error("请先保存当前章节,再运行 AI 分析");
const sourceText = editor?.getText().trim() || stripHtml(unit.sourceText);
if (!sourceText) throw new Error("当前章节没有可分析的正文");
const textModel = effectiveConfig.textModel || effectiveConfig.model;
if (!isAiConfigReady(effectiveConfig, textModel)) {
navigateToSettings({ continueCreation: true });
return null;
}
return {
projectId: detail.project.id,
projectName: detail.project.name,
chapterId: unit.id,
chapterTitle: unit.title,
sourceText,
projectStyle: canvasStylePresets.find((preset) => preset.id === detail.project.stylePresetId)?.prompt || "",
config: effectiveConfig,
};
};
const extractCharacters = async () => {
try {
const input = chapterAnalysisInput();
if (!input) return;
setAnalysisKind("characters");
const characters = await extractChapterCharacters(input);
// 已确认角色允许再次提取并作为候选归并,只有尚待处理的同名候选需要去重。
const knownNames = new Set(detail.assetCandidates
.filter((candidate) => candidate.category === "character" && candidate.status === "pending_confirmation")
.map((candidate) => normalizeCharacterName(candidate.name)));
const fresh = characters.filter((character) => ![character.name, ...character.aliases].map(normalizeCharacterName).some((name) => knownNames.has(name)));
if (!fresh.length) {
message.info("本章角色已存在于待确认列表中");
return;
}
await createProjectAssetCandidates(detail.project.id, fresh.map((character) => ({ unitId: input.chapterId, name: character.name, category: "character", details: { ...character } })));
refreshProject();
message.success(`已提取 ${fresh.length} 个角色,请到项目资产确认`);
} catch (error) {
message.error(error instanceof Error ? error.message : "角色提取失败");
} finally {
setAnalysisKind("");
}
};
const replaceStoryboard = async () => {
try {
const input = chapterAnalysisInput();
if (!input) return;
setAnalysisKind("storyboard");
const shots = await generateChapterStoryboard(input);
await replaceProjectUnitShots(detail.project.id, input.chapterId, shots);
refreshProject();
message.success(`已生成 ${shots.length} 个章节分镜`);
} catch (error) {
message.error(error instanceof Error ? error.message : "分镜生成失败");
} finally {
setAnalysisKind("");
}
};
const generateStoryboard = () => {
const existingCount = selectedUnit ? chapterShotCount(selectedUnit.id) : 0;
if (!existingCount) {
void replaceStoryboard();
return;
}
modal.confirm({
title: "重新生成本章分镜?",
content: `当前 ${existingCount} 个镜头会被新分镜整体替换,已关联的镜头素材不会删除,但会解除镜头引用。`,
okText: "重新生成",
cancelText: "取消",
onOk: replaceStoryboard,
});
};
const selectChapter = (unitId: string) => {
if (unitId === selectedId) return;
if (dirty) { message.warning("请先保存当前章节,再切换章节"); return; }
setSelectedId(unitId);
sessionStorage.setItem(`project-active-chapter:${detail.project.id}`, unitId);
navigate(`/projects/${detail.project.id}/chapters/${unitId}`);
};
const moveChapter = (targetId: string) => {
if (!draggedId || draggedId === targetId || reorderMutation.isPending) return;
const next = orderedIds.filter((id) => id !== draggedId);
next.splice(next.indexOf(targetId), 0, draggedId);
setOrderedIds(next);
setDraggedId("");
reorderMutation.mutate(next);
};
const moveChapterToPosition = () => {
if (!moveTargetId || !movePosition || reorderMutation.isPending) return;
const next = orderedIds.filter((id) => id !== moveTargetId);
const targetIndex = Math.min(Math.max(movePosition - 1, 0), next.length);
next.splice(targetIndex, 0, moveTargetId);
setOrderedIds(next);
setMoveTargetId("");
setMovePosition(null);
reorderMutation.mutate(next);
window.setTimeout(() => {
const index = next.indexOf(moveTargetId);
const visibleIndex = visibleUnits.findIndex((unit) => unit.id === moveTargetId);
if (!deferredSearchQuery && index >= 0) chapterVirtualizer.scrollToIndex(index, { align: "center" });
else if (visibleIndex >= 0) chapterVirtualizer.scrollToIndex(visibleIndex, { align: "center" });
}, 0);
};
const revealSelectedChapter = () => {
if (!selectedId) return;
setSearchQuery("");
window.setTimeout(() => {
const index = orderedIds.indexOf(selectedId);
if (index >= 0) chapterVirtualizer.scrollToIndex(index, { align: "center" });
}, 0);
};
const handleListDragOver = (event: DragEvent<HTMLDivElement>) => {
if (!draggedId || deferredSearchQuery) return;
event.preventDefault();
const bounds = event.currentTarget.getBoundingClientRect();
const edge = 48;
if (event.clientY < bounds.top + edge) event.currentTarget.scrollBy({ top: -24 });
else if (event.clientY > bounds.bottom - edge) event.currentTarget.scrollBy({ top: 24 });
};
return (
<div className="grid h-full min-h-0 min-w-0 w-full grid-rows-[minmax(180px,34vh)_minmax(0,1fr)] overflow-hidden lg:grid-cols-[232px_minmax(0,1fr)] lg:grid-rows-1">
<aside className="flex min-h-0 min-w-0 w-full flex-col border-b border-border/70 bg-background/28 lg:border-b-0 lg:border-r">
<div className="flex h-11 shrink-0 items-center justify-between border-b border-border/70 px-2.5">
<div className="text-xs font-medium text-foreground/55">章节 <span className="ml-1 tabular-nums text-foreground/35">{detail.units.length.toLocaleString("zh-CN")}</span></div>
<div className="flex items-center gap-0.5">
{selectedId ? <Tooltip title="回到当前章节"><Button type="text" size="small" icon={<Crosshair className="size-3.5" />} aria-label="回到当前章节" onClick={revealSelectedChapter} /></Tooltip> : null}
<Tooltip title="导入小说"><Button type="text" size="small" icon={<FileUp className="size-3.5" />} aria-label="导入小说" onClick={() => setImportOpen(true)} /></Tooltip>
<Tooltip title="添加章节"><Button type="text" size="small" icon={<Plus className="size-4" />} aria-label="添加章节" onClick={() => setCreateOpen(true)} /></Tooltip>
</div>
</div>
<div className="shrink-0 border-b border-border/60 p-2">
<label className="flex h-8 items-center gap-1.5 rounded-md border border-border/75 bg-background/55 px-2 focus-within:border-[var(--workspace-accent)] focus-within:ring-2 focus-within:ring-[var(--workspace-accent-soft)]">
<Search className="size-3.5 shrink-0 text-foreground/32" />
<input value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && visibleUnits[0]) selectChapter(visibleUnits[0].id); }} className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-foreground/28" placeholder="搜索标题或章节序号" aria-label="搜索章节" />
{searchQuery ? <button type="button" onClick={() => setSearchQuery("")} className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-foreground/[.06]" aria-label="清空章节搜索"><X className="size-3" /></button> : null}
</label>
{deferredSearchQuery ? <div className="mt-1 px-0.5 text-[9px] tabular-nums text-foreground/35">找到 {visibleUnits.length.toLocaleString("zh-CN")} 章 · 搜索时使用“移动到”调整顺序</div> : null}
</div>
{orderedUnits.length ? (
<div ref={listRef} onDragOver={handleListDragOver} className="thin-scrollbar min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain p-1.5">
{visibleUnits.length ? <div className="relative w-full" style={{ height: chapterVirtualizer.getTotalSize() }}>
{chapterVirtualizer.getVirtualItems().map((virtualItem) => {
const unit = visibleUnits[virtualItem.index];
if (!unit) return null;
const chapterNumber = chapterNumberById.get(unit.id) || virtualItem.index + 1;
return (
<div key={unit.id} className="absolute left-0 top-0 w-full" style={{ height: virtualItem.size, transform: `translateY(${virtualItem.start}px)` }}>
<div draggable={!dirty && !reorderMutation.isPending && !deferredSearchQuery} onDragStart={(event) => { setDraggedId(unit.id); event.dataTransfer.effectAllowed = "move"; }} onDragOver={(event) => { if (deferredSearchQuery) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={() => moveChapter(unit.id)} onDragEnd={() => setDraggedId("")} className={`group flex h-[50px] items-start rounded-md transition-colors duration-100 ${unit.id === selectedUnit?.id ? "bg-foreground/[.08]" : "hover:bg-foreground/[.04]"} ${draggedId === unit.id ? "opacity-45" : ""}`}>
<button type="button" disabled={Boolean(deferredSearchQuery)} className="mt-2 grid size-6 shrink-0 cursor-grab place-items-center text-foreground/25 active:cursor-grabbing disabled:cursor-default disabled:opacity-35" aria-label={`拖动第 ${chapterNumber} 章排序`}><GripVertical className="size-3.5" /></button>
<button type="button" onClick={() => selectChapter(unit.id)} className="flex min-w-0 flex-1 items-start gap-2 px-0 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]">
<span className={`w-8 shrink-0 pt-0.5 text-[10px] tabular-nums ${unit.id === selectedUnit?.id ? "font-semibold text-[var(--workspace-accent)]" : "text-foreground/35"}`}>{String(chapterNumber).padStart(Math.max(2, String(orderedUnits.length).length), "0")}</span>
<span className="min-w-0 flex-1"><span className={`block truncate text-[13px] ${unit.id === selectedUnit?.id ? "font-medium text-foreground" : "text-foreground/65"}`}>{unit.title}</span><span className="mt-0.5 flex items-center gap-1 text-[10px] text-foreground/38"><span>{statusLabel(unit.status)}</span>{chapterCanvasCount(unit.id) ? <><span>·</span><span>{chapterCanvasCount(unit.id)} 画布</span></> : null}</span></span>
</button>
<Dropdown trigger={["click"]} placement="bottomRight" menu={{ items: [{ key: "move", icon: <MoveVertical className="size-3.5" />, label: "移动到…" }], onClick: () => { setMoveTargetId(unit.id); setMovePosition(chapterNumber); } }}>
<button type="button" className="mt-2 grid size-6 shrink-0 place-items-center rounded text-foreground/28 opacity-0 hover:bg-foreground/[.06] hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100" aria-label={`${unit.title}更多操作`}><MoreHorizontal className="size-3.5" /></button>
</Dropdown>
<Popconfirm title="删除此章节?" description="章节内容及关联制作记录将被删除,已关联的画布不会删除。" okText="删除" cancelText="取消" okButtonProps={{ danger: true, loading: deleteMutation.isPending }} onConfirm={() => deleteMutation.mutate(unit.id)}>
<button type="button" className="mr-1 mt-2 grid size-6 shrink-0 place-items-center rounded text-foreground/28 opacity-0 hover:bg-red-500/10 hover:text-red-500 group-hover:opacity-100 focus-visible:opacity-100" aria-label={`删除${unit.title}`}><Trash2 className="size-3.5" /></button>
</Popconfirm>
</div>
</div>
);
})}
</div> : <div className="px-3 py-8 text-center text-xs text-foreground/40">没有匹配的章节</div>}
</div>
) : <WorkspaceState icon="projects" compact className="flex-1 px-4" title="还没有章节" description="添加章节后可继续编写正文和关联画布。" action={<Button size="small" type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>添加章节</Button>} />}
</aside>
<section className="min-h-0 min-w-0 w-full bg-background/18 p-2.5 sm:p-3.5">
{selectedUnit ? (
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden rounded-lg border border-border/80 bg-background">
<header className="flex shrink-0 items-start gap-3 border-b border-border/70 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="mb-1 text-[10px] font-medium tabular-nums text-foreground/38">第 {String(orderedUnits.findIndex((unit) => unit.id === selectedUnit.id) + 1).padStart(2, "0")} 章</div>
<Input variant="borderless" value={draftTitle} disabled={!selectedUnitQuery.data?.unit} onChange={(event) => { setDraftTitle(event.target.value); setDirty(true); }} className="!h-auto !px-0 !py-0 !text-xl !font-semibold !leading-tight disabled:!cursor-wait disabled:!text-foreground" placeholder="章节标题" />
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[10px] text-foreground/38"><span>{dirty ? "有未保存修改" : `保存于 ${formatTime(selectedUnit.updatedAt)}`}</span><span>·</span><span>{formatCount(wordCount)} 字</span><span>·</span><span>{chapterCanvasCount(selectedUnit.id)} 个画布</span></div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button size="small" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || dirty || Boolean(analysisKind)} loading={analysisKind === "characters"} onClick={() => void extractCharacters()}>提取角色</Button>
<Button size="small" icon={<Clapperboard className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || dirty || Boolean(analysisKind)} loading={analysisKind === "storyboard"} onClick={generateStoryboard}>{chapterShotCount(selectedUnit.id) ? "重新生成分镜" : "生成分镜"}</Button>
<Button size="small" type={dirty ? "primary" : "default"} icon={dirty ? <Save className="size-3.5" /> : <Check className="size-3.5" />} disabled={!selectedUnitQuery.data?.unit || !dirty || !draftTitle.trim() || saveMutation.isPending} loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{dirty ? "保存" : "已保存"}</Button>
</div>
</header>
<EditorToolbar editor={editor} />
<div className="project-chapter-editor-scroll thin-scrollbar min-h-0 flex-1 overflow-y-auto bg-foreground/[.012]">
{selectedUnitQuery.isLoading ? <WorkspaceState icon="loading" compact className="h-full" title="正在读取章节正文" description="正文准备完成后会自动显示。" /> : selectedUnitQuery.isError ? <WorkspaceErrorState compact title="章节正文读取失败" description={selectedUnitQuery.error instanceof Error ? selectedUnitQuery.error.message : "请检查网络连接后重试。"} onRetry={() => void selectedUnitQuery.refetch()} /> : <div className="project-chapter-editor-wrap min-h-full"><EditorContent editor={editor} /></div>}
</div>
</div>
) : <WorkspaceState icon="projects" compact className="h-full" title="请选择章节" description="从左侧章节列表选择一章开始编辑。" />}
</section>
<CreateChapterModal open={createOpen} onClose={() => setCreateOpen(false)} loading={createMutation.isPending} onSubmit={(values) => createMutation.mutate(values)} />
<ImportNovelModal open={importOpen} loading={importMutation.isPending} onClose={() => setImportOpen(false)} onImport={(chapters) => importMutation.mutate(chapters)} />
<Modal title="移动章节" open={Boolean(moveTargetId)} width={400} okText="移动" cancelText="取消" okButtonProps={{ disabled: !movePosition || movePosition < 1 || movePosition > orderedUnits.length, loading: reorderMutation.isPending }} onCancel={() => { setMoveTargetId(""); setMovePosition(null); }} onOk={moveChapterToPosition} styles={{ body: { paddingTop: 12 } }}>
<div className="text-xs leading-5 text-foreground/50">输入目标章节位置。适合上千章项目的长距离调整,移动后其他章节会自动顺延。</div>
<label className="mt-3 flex items-center gap-2 text-sm"><span className="shrink-0">移动到第</span><InputNumber min={1} max={orderedUnits.length} precision={0} value={movePosition} onChange={setMovePosition} className="min-w-0 flex-1" /><span className="shrink-0">章</span></label>
</Modal>
</div>
);
}
function EditorToolbar({ editor }: { editor: Editor | null }) {
const setLink = () => {
if (!editor) return;
const current = String(editor.getAttributes("link").href || "");
const href = window.prompt("输入链接地址", current);
if (href === null) return;
if (!href.trim()) editor.chain().focus().unsetLink().run();
else editor.chain().focus().extendMarkRange("link").setLink({ href: href.trim() }).run();
};
const setColor = () => {
const color = window.prompt("输入文字颜色,例如 #d97706", String(editor?.getAttributes("textStyle").color || "#d97706"));
if (color?.trim()) editor?.chain().focus().setColor(color.trim()).run();
};
const setHighlight = () => {
const color = window.prompt("输入高亮颜色,例如 #fef3c7", String(editor?.getAttributes("highlight").color || "#fef3c7"));
if (color?.trim()) editor?.chain().focus().toggleHighlight({ color: color.trim() }).run();
};
const blockLabel = editor?.isActive("heading", { level: 1 }) ? "标题 1" : editor?.isActive("heading", { level: 2 }) ? "标题 2" : editor?.isActive("heading", { level: 3 }) ? "标题 3" : "正文";
const alignment = editor?.isActive({ textAlign: "center" }) ? "center" : editor?.isActive({ textAlign: "right" }) ? "right" : editor?.isActive({ textAlign: "justify" }) ? "justify" : "left";
const alignmentIcon = alignment === "center" ? <AlignCenter className="size-3.5" /> : alignment === "right" ? <AlignRight className="size-3.5" /> : alignment === "justify" ? <AlignJustify className="size-3.5" /> : <AlignLeft className="size-3.5" />;
return (
<div className="hide-scrollbar flex h-10 shrink-0 items-center gap-0.5 overflow-x-auto border-b border-border/70 px-2">
<EditorTool editor={editor} label="撤销" icon={<Undo2 className="size-3.5" />} onClick={() => editor?.chain().focus().undo().run()} />
<EditorTool editor={editor} label="重做" icon={<Redo2 className="size-3.5" />} onClick={() => editor?.chain().focus().redo().run()} />
<ToolbarDivider />
<Dropdown trigger={["click"]} menu={{ selectedKeys: [blockLabel], items: ["正文", "标题 1", "标题 2", "标题 3"].map((key) => ({ key, label: key })), onClick: ({ key }) => key === "正文" ? editor?.chain().focus().setParagraph().run() : editor?.chain().focus().toggleHeading({ level: Number(key.slice(-1)) as 1 | 2 | 3 }).run() }}>
<button type="button" className="flex h-7 items-center gap-1 rounded px-2 text-xs text-foreground/60 hover:bg-foreground/[.06]" aria-label="段落格式">{blockLabel}<ChevronDown className="size-3" /></button>
</Dropdown>
<EditorTool editor={editor} label="粗体" icon={<Bold className="size-3.5" />} onClick={() => editor?.chain().focus().toggleBold().run()} active={Boolean(editor?.isActive("bold"))} />
<EditorTool editor={editor} label="斜体" icon={<Italic className="size-3.5" />} onClick={() => editor?.chain().focus().toggleItalic().run()} active={Boolean(editor?.isActive("italic"))} />
<EditorTool editor={editor} label="下划线" icon={<Underline className="size-3.5" />} onClick={() => editor?.chain().focus().toggleUnderline().run()} active={Boolean(editor?.isActive("underline"))} />
<EditorTool editor={editor} label="删除线" icon={<Strikethrough className="size-3.5" />} onClick={() => editor?.chain().focus().toggleStrike().run()} active={Boolean(editor?.isActive("strike"))} />
<ToolbarDivider />
<Dropdown trigger={["click"]} menu={{ selectedKeys: [alignment], items: [{ key: "left", icon: <AlignLeft className="size-3.5" />, label: "左对齐" }, { key: "center", icon: <AlignCenter className="size-3.5" />, label: "居中" }, { key: "right", icon: <AlignRight className="size-3.5" />, label: "右对齐" }, { key: "justify", icon: <AlignJustify className="size-3.5" />, label: "两端对齐" }], onClick: ({ key }) => editor?.chain().focus().setTextAlign(key).run() }}>
<button type="button" className="grid size-7 place-items-center rounded text-foreground/60 hover:bg-foreground/[.06]" aria-label="文字对齐">{alignmentIcon}</button>
</Dropdown>
<EditorTool editor={editor} label="项目符号" icon={<List className="size-3.5" />} onClick={() => editor?.chain().focus().toggleBulletList().run()} active={Boolean(editor?.isActive("bulletList"))} />
<EditorTool editor={editor} label="编号列表" icon={<ListOrdered className="size-3.5" />} onClick={() => editor?.chain().focus().toggleOrderedList().run()} active={Boolean(editor?.isActive("orderedList"))} />
<EditorTool editor={editor} label="引用" icon={<Quote className="size-3.5" />} onClick={() => editor?.chain().focus().toggleBlockquote().run()} active={Boolean(editor?.isActive("blockquote"))} />
<EditorTool editor={editor} label="链接" icon={<Link2 className="size-3.5" />} onClick={setLink} active={Boolean(editor?.isActive("link"))} />
<Dropdown trigger={["click"]} placement="bottomRight" menu={{ items: [{ key: "color", icon: <span className="text-[11px] font-bold text-amber-600">A</span>, label: "文字颜色" }, { key: "highlight", icon: <Highlighter className="size-3.5" />, label: "高亮颜色" }, { type: "divider" }, { key: "code", icon: <Code2 className="size-3.5" />, label: "行内代码" }, { key: "rule", icon: <Minus className="size-3.5" />, label: "分隔线" }, { key: "clear", icon: <Eraser className="size-3.5" />, label: "清除格式" }], onClick: ({ key }) => { if (key === "color") setColor(); else if (key === "highlight") setHighlight(); else if (key === "code") editor?.chain().focus().toggleCode().run(); else if (key === "rule") editor?.chain().focus().setHorizontalRule().run(); else if (key === "clear") editor?.chain().focus().clearNodes().unsetAllMarks().run(); } }}>
<button type="button" className="grid size-7 place-items-center rounded text-foreground/60 hover:bg-foreground/[.06]" aria-label="更多格式"><MoreHorizontal className="size-4" /></button>
</Dropdown>
</div>
);
}
function EditorTool({ editor, label, icon, active = false, onClick }: { editor: Editor | null; label: string; icon: ReactNode; active?: boolean; onClick: () => void }) {
return <Tooltip title={label}><button type="button" aria-label={label} className={`grid size-7 shrink-0 place-items-center rounded ${active ? "bg-foreground/[.08] text-[var(--workspace-accent)]" : "text-foreground/55 hover:bg-foreground/[.06] hover:text-foreground"}`} disabled={!editor} onClick={onClick}>{icon}</button></Tooltip>;
}
function ToolbarDivider() {
return <span className="mx-1 h-4 w-px shrink-0 bg-border" />;
}
function CreateChapterModal({ open, onClose, loading, onSubmit }: { open: boolean; onClose: () => void; loading: boolean; onSubmit: (values: { title: string; sourceText?: string }) => void }) {
return <Modal title="添加章节" open={open} footer={null} destroyOnHidden onCancel={onClose} width={480} styles={{ body: { paddingTop: 12 } }}><Form layout="vertical" onFinish={onSubmit}><Form.Item name="title" label="章节标题" rules={[{ required: true, whitespace: true, message: "请输入章节标题" }]}><Input autoFocus placeholder="例如:雨夜归城" /></Form.Item><Form.Item name="sourceText" label="正文(可选)"><Input.TextArea rows={4} placeholder="创建后仍可继续编辑和排版" /></Form.Item><div className="flex justify-end gap-2"><Button onClick={onClose}>取消</Button><Button type="primary" htmlType="submit" loading={loading}>创建章节</Button></div></Form></Modal>;
}
function ImportNovelModal({ open, loading, onClose, onImport }: { open: boolean; loading: boolean; onClose: () => void; onImport: (chapters: Array<{ title: string; plainText: string }>) => void }) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState("");
const [fileName, setFileName] = useState("");
const deferredText = useDeferredValue(text);
const chapters = useMemo(() => deferredText.trim() ? splitTextIntoChapters(deferredText).map((chapter) => ({ title: chapter.title, plainText: chapter.plainText })) : [], [deferredText]);
useEffect(() => { if (!open) { setText(""); setFileName(""); } }, [open]);
const readFile = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
setFileName(file.name);
setText(decodeNovelText(await file.arrayBuffer()));
};
return (
<Modal title={null} open={open} footer={null} destroyOnHidden onCancel={onClose} width={760} styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}>
<div className="flex min-h-[478px] flex-col">
<header className="flex h-12 shrink-0 items-center border-b border-border px-4"><div><h2 className="text-sm font-semibold">导入小说</h2><p className="mt-0.5 text-[10px] text-foreground/42">自动识别章节标题,确认后追加到当前项目</p></div></header>
<div className="grid min-h-[430px] flex-1 grid-cols-1 md:grid-cols-[minmax(0,1fr)_240px]">
<div className="border-b border-border p-3 md:border-b-0 md:border-r">
<div className="mb-2 flex items-center justify-between gap-2"><div><div className="text-sm font-medium">小说正文</div><div className="mt-0.5 text-[11px] text-foreground/45">识别章节标题后追加到现有章节</div></div><Button size="small" icon={<FileUp className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>{fileName || "选择 TXT"}</Button></div>
<input ref={fileInputRef} type="file" accept=".txt,.md,text/plain,text/markdown" className="hidden" onChange={(event) => void readFile(event)} />
<Input.TextArea value={text} onChange={(event) => setText(event.target.value)} rows={16} placeholder={'也可以直接粘贴小说正文,例如:\n\n第一章 雨夜来信\n正文……\n\n第二章 灯塔以北\n正文……'} className="!resize-none" />
</div>
<div className="flex min-h-0 flex-col">
<div className="flex h-11 shrink-0 items-center justify-between border-b border-border px-3 text-xs"><span className="font-medium">拆分预览</span><span className="tabular-nums text-foreground/45">{chapters.length} 章</span></div>
<ImportChapterPreview chapters={chapters} />
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border p-3"><span className={`text-[10px] ${chapters.length > MAX_NOVEL_IMPORT_CHAPTERS ? "text-red-500" : "text-foreground/38"}`}>{chapters.length > MAX_NOVEL_IMPORT_CHAPTERS ? `最多一次导入 ${MAX_NOVEL_IMPORT_CHAPTERS.toLocaleString("zh-CN")} 章` : `支持最多 ${MAX_NOVEL_IMPORT_CHAPTERS.toLocaleString("zh-CN")} 章`}</span><div className="flex gap-2"><Button size="small" onClick={onClose}>取消</Button><Button size="small" type="primary" disabled={!chapters.length || chapters.length > MAX_NOVEL_IMPORT_CHAPTERS} loading={loading} onClick={() => onImport(chapters)}>导入 {chapters.length || ""} 章</Button></div></div>
</div>
</div>
</div>
</Modal>
);
}
function ImportChapterPreview({ chapters }: { chapters: Array<{ title: string; plainText: string }> }) {
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: chapters.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 49,
overscan: 10,
});
return (
<div ref={scrollRef} className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-2">
{chapters.length ? <div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const chapter = chapters[virtualItem.index];
return <div key={`${chapter.title}-${virtualItem.index}`} className="absolute left-0 top-0 flex w-full gap-2 border-b border-border/60 px-1.5 py-2" style={{ height: virtualItem.size, transform: `translateY(${virtualItem.start}px)` }}><span className="w-8 shrink-0 pt-0.5 text-[10px] tabular-nums text-foreground/35">{String(virtualItem.index + 1).padStart(Math.max(2, String(chapters.length).length), "0")}</span><div className="min-w-0"><div className="truncate text-xs font-medium">{chapter.title}</div><div className="mt-0.5 text-[10px] text-foreground/40">{formatCount(chapter.plainText.length)} 字</div></div></div>;
})}
</div> : <div className="grid h-full place-items-center px-4 text-center text-xs leading-5 text-foreground/40">选择 TXT 文件或粘贴正文后,这里会显示拆分结果</div>}
</div>
);
}
function plainTextToHtml(value: string) {
const escaped = value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return escaped.split(/\n{2,}/).map((paragraph) => `<p>${paragraph.replace(/\n/g, "<br>")}</p>`).join("");
}
function stripHtml(value: string) {
return value.replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").trim();
}
function readStoredScroll(key: string) {
const value = Number(sessionStorage.getItem(key) || 0);
return Number.isFinite(value) && value > 0 ? value : 0;
}