fix(canvas): 短剧工作流 - 保留角色候选并打通章节分镜入画布

This commit is contained in:
ddCat
2026-07-28 09:36:44 +08:00
parent a2911ad32f
commit dfebe27c5e
5 changed files with 219 additions and 15 deletions

View File

@@ -0,0 +1,88 @@
import { createCanvasNode, createStoryboardRow } from "@/lib/canvas/canvas-project-domain";
import type { ProjectShot, ProjectUnit } from "@/services/api/projects";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type StoryboardData, type StoryboardRow } from "@/types/canvas";
type ProjectChapterStoryboardInput = {
unit: Pick<ProjectUnit, "id" | "title">;
shots: ProjectShot[];
};
export function upsertProjectChapterStoryboard(
nodes: CanvasNodeData[],
connections: CanvasConnection[],
{ unit, shots }: ProjectChapterStoryboardInput,
) {
const existing = nodes.find((node) => node.type === CanvasNodeType.Script && node.metadata?.chapterId === unit.id);
const currentRows = new Map((existing?.metadata?.storyboard?.rows || []).map((row) => [row.id, row]));
const rows = shots
.filter((shot) => shot.unitId === unit.id)
.slice()
.sort((left, right) => left.position - right.position)
.map((shot, index) => projectShotRow(shot, index, currentRows));
const storyboard: StoryboardData = {
rows,
visibleColumns: existing?.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
referenceNodeIds: existing?.metadata?.storyboard?.referenceNodeIds || [],
};
const scriptNode: CanvasNodeData = existing
? {
...existing,
title: `分镜脚本 · ${unit.title}`,
metadata: {
...existing.metadata,
status: "idle" as const,
workflowKind: "storyboard" as const,
workflowTitle: "章节分镜",
workflowDescription: `已导入 ${rows.length} 个镜头`,
chapterId: unit.id,
chapterTitle: unit.title,
storyboard,
},
}
: createChapterStoryboardNode(nodes, unit, rows);
const nextNodes = existing ? nodes.map((node) => node.id === existing.id ? scriptNode : node) : [...nodes, scriptNode];
const validRowHandles = new Set(rows.map((row) => `row:${row.id}`));
const nextConnections = existing
? connections
.filter((connection) => connection.fromNodeId !== existing.id || validStoryboardHandle(connection.fromHandleId, validRowHandles))
.filter((connection) => connection.toNodeId !== existing.id || validStoryboardHandle(connection.toHandleId, validRowHandles))
: connections;
return { nodes: nextNodes, connections: nextConnections, scriptNodeId: scriptNode.id, rowCount: rows.length };
}
function projectShotRow(shot: ProjectShot, index: number, currentRows: Map<string, StoryboardRow>) {
const id = `project-shot:${shot.id}`;
const current = currentRows.get(id);
return createStoryboardRow(index + 1, {
...current,
id,
shotNumber: index + 1,
durationSeconds: Math.max(1, Math.round(shot.durationMs / 1000) || 1),
plotDescription: shot.description.trim() || shot.title.trim(),
status: current?.status || "idle",
});
}
function createChapterStoryboardNode(nodes: CanvasNodeData[], unit: Pick<ProjectUnit, "id" | "title">, rows: StoryboardRow[]) {
const rightEdge = nodes.reduce((value, node) => Math.max(value, node.position.x + node.width), 0);
const node = createCanvasNode(CanvasNodeType.Script, { x: rightEdge + 540, y: 340 }, {
status: "idle",
workflowKind: "storyboard",
workflowTitle: "章节分镜",
workflowDescription: `已导入 ${rows.length} 个镜头`,
chapterId: unit.id,
chapterTitle: unit.title,
storyboard: {
rows,
visibleColumns: ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
referenceNodeIds: [],
},
});
node.title = `分镜脚本 · ${unit.title}`;
return node;
}
function validStoryboardHandle(handleId: string | undefined, validRowHandles: Set<string>) {
return !handleId || handleId === "storyboard:context" || validRowHandles.has(handleId);
}

View File

@@ -4,10 +4,11 @@ import { ArrowLeft, BookOpenText, Images, LayoutDashboard, LayoutGrid, Plus, Set
import { Link, Navigate, useNavigate, useParams } from "react-router";
import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync";
import { getProject } from "@/services/api/projects";
import { getProject, linkCanvasUnit } from "@/services/api/projects";
import { WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceErrorState, WorkspaceLoadingState } from "@/components/layout/workspace-state";
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
import { upsertProjectChapterStoryboard } from "@/lib/canvas/project-chapter-storyboard";
import ProjectAssetsView from "./detail/assets";
import ProjectCanvasesView from "./detail/canvases";
@@ -35,9 +36,31 @@ export default function ProjectDetailPage() {
const refreshProject = () => { void queryClient.invalidateQueries({ queryKey: ["project", projectId] }); void queryClient.invalidateQueries({ queryKey: ["projects"] }); };
const createCanvas = () => {
if (detail.data?.project.status === "archived") { message.warning("项目已归档,请先在项目设置中恢复"); return; }
void createCanvasProjectWithRemoteSync(`${detail.data?.project.name || "项目"} · 新画布`, projectId).then(({ id, syncError }) => {
if (syncError) message.warning(syncError instanceof Error ? `画布已创建,项目关联稍后重试:${syncError.message}` : "画布已创建,项目关联稍后重试");
else refreshProject();
const activeChapterId = chapterId || sessionStorage.getItem(`project-active-chapter:${projectId}`) || "";
const unit = activeView === "chapters"
? detail.data?.units.find((item) => item.id === activeChapterId) || detail.data?.units.slice().sort((left, right) => left.position - right.position)[0]
: undefined;
const shots = unit ? detail.data?.shots.filter((shot) => shot.unitId === unit.id) || [] : [];
const seed = unit && shots.length ? upsertProjectChapterStoryboard([], [], { unit, shots }) : undefined;
const initialContent = seed ? { nodes: seed.nodes, connections: seed.connections } : undefined;
const title = unit ? `${unit.title} · ${shots.length ? "分镜画布" : "画布"}` : `${detail.data?.project.name || "项目"} · 新画布`;
void createCanvasProjectWithRemoteSync(title, projectId, initialContent).then(async ({ id, syncError }) => {
if (syncError) {
message.warning(syncError instanceof Error ? `画布已保存在本地,项目关联稍后重试:${syncError.message}` : "画布已保存在本地,项目关联稍后重试");
navigate(`/canvas/${id}`);
return;
}
if (unit) {
try {
await linkCanvasUnit(projectId, { canvasId: id, unitId: unit.id, role: "storyboard" });
} catch (error) {
refreshProject();
message.error(error instanceof Error ? `画布已创建,但章节关联失败:${error.message}` : "画布已创建,但章节关联失败");
return;
}
}
refreshProject();
message.success(unit && shots.length ? `已创建章节画布并导入 ${shots.length} 个分镜` : unit ? "章节画布已创建并关联" : "项目画布已创建");
navigate(`/canvas/${id}`);
}).catch((error) => message.error(error instanceof Error ? error.message : "画布创建失败"));
};
@@ -63,7 +86,7 @@ export default function ProjectDetailPage() {
<nav className="thin-scrollbar order-last mt-1 flex h-11 w-full min-w-0 items-center gap-0.5 overflow-x-auto lg:order-none lg:mt-0 lg:h-16 lg:flex-1 lg:border-l lg:border-border/55 lg:pl-3" aria-label="项目导航">
{views.map((item) => { const Icon = item.icon; const active = item.key === activeView; const href = item.key === "chapters" ? chapterHref : `/projects/${projectId}/${item.key}`; return <Link key={item.key} to={href} className={`relative flex h-11 shrink-0 items-center gap-2 rounded-md px-2.5 text-[13px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-3 ${active ? "bg-[var(--workspace-accent-soft)] font-medium text-foreground lg:after:absolute lg:after:inset-x-3 lg:after:bottom-0 lg:after:h-0.5 lg:after:rounded-full lg:after:bg-[var(--workspace-accent)]" : "text-foreground/52 hover:bg-foreground/[.045] hover:text-foreground"}`} aria-current={active ? "page" : undefined}><Icon className={`size-4 shrink-0 ${active ? "text-[var(--workspace-accent)]" : "text-foreground/45"}`} /><span className="sm:hidden">{item.shortLabel}</span><span className="hidden sm:inline">{item.label}</span></Link>; })}
</nav>
<Tooltip title="新建项目画布"><Button size="small" className="!h-9 !shrink-0 !px-2 sm:!px-3" icon={<Plus className="size-4" />} onClick={createCanvas} aria-label="新建项目画布"><span className="hidden sm:inline">新建画布</span></Button></Tooltip>
<Tooltip title={activeView === "chapters" && detail.data.units.length ? "新建当前章节画布" : "新建项目画布"}><Button size="small" className="!h-9 !shrink-0 !px-2 sm:!px-3" icon={<Plus className="size-4" />} onClick={createCanvas} aria-label={activeView === "chapters" && detail.data.units.length ? "新建当前章节画布" : "新建项目画布"}><span className="hidden sm:inline">新建画布</span></Button></Tooltip>
</div>
</header>
{detail.data.project.status === "archived" ? <Alert type="warning" showIcon banner message="项目已归档,恢复后才能创建画布和生成任务" className="!border-x-0 !border-t-0" /> : null}

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { useMutation, useMutationState, useQuery } from "@tanstack/react-query";
import { useMutation, useMutationState, useQuery, useQueryClient } from "@tanstack/react-query";
import { App, Button, Dropdown, Form, Input, Modal, Popconfirm, Select, Tabs, type FormInstance } from "antd";
import { Box, Check, ChevronDown, FileText, Image as ImageIcon, Link2, Music2, Plus, RefreshCw, Sparkles, Trash2, UserRound, Video, VolumeX } from "lucide-react";
@@ -21,6 +21,7 @@ import {
updateProjectAssetCategory,
updateProjectCharacter,
type ProjectAsset,
type ProjectDetail,
} from "@/services/api/projects";
import { saveRemoteUserDataNow } from "@/services/user-data-sync";
import { useAssetStore, type Asset, type AssetCategory, type AssetStatus, type EntityAsset, type ImageAsset } from "@/stores/use-asset-store";
@@ -42,6 +43,7 @@ type CharacterForm = { name: string } & Record<(typeof characterFields)[number][
export default function ProjectAssetsView({ detail, refreshProject }: ProjectDetailViewProps) {
const { message } = App.useApp();
const queryClient = useQueryClient();
const personalAssets = useAssetStore((state) => state.assets);
const updatePersonalAsset = useAssetStore((state) => state.updateAsset);
const effectiveConfig = useEffectiveConfig();
@@ -66,7 +68,12 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const characterAssets = detail.assets.filter((asset) => asset.category === "character" && asset.character);
const mediaAssetCount = detail.assets.filter((asset) => asset.category !== "character").length;
const visibleAssets = useMemo(() => category === "all" ? detail.assets : detail.assets.filter((asset) => asset.category === category), [category, detail.assets]);
const categoryCounts = categories.map((value) => ({ value, count: value === "all" ? detail.assets.length : detail.assets.filter((asset) => asset.category === value).length }));
const categoryCounts = categories.map((value) => ({
value,
count: value === "all"
? detail.assets.length + pendingCandidates.length
: detail.assets.filter((asset) => asset.category === value).length + (value === "character" ? pendingCandidates.length : 0),
}));
const voices = useQuery({ queryKey: ["voice-profiles"], queryFn: listVoiceProfiles, enabled: Boolean(voiceAsset) });
const generatingAssets = useMutationState({
filters: { mutationKey: ["project-character-turnaround", detail.project.id], status: "pending" },
@@ -82,9 +89,19 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const categoryMutation = useMutation({ mutationFn: ({ id, next }: { id: string; next: string }) => updateProjectAssetCategory(detail.project.id, id, next), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory }); done("资产分类已更新"); }, onError: failed("资产分类更新失败") });
const confirmMutation = useMutation({
mutationFn: ({ candidateId, targetAssetId }: { candidateId: string; targetAssetId?: string }) => confirmProjectAssetCandidate(detail.project.id, candidateId, targetAssetId),
onSuccess: ({ asset }, variables) => { syncPersonalCharacterProjection(asset); done(variables.targetAssetId ? "候选信息已归并到角色新版本" : "角色卡已创建"); },
onSuccess: ({ asset }, variables) => {
// 候选确认后先精确更新当前项,剩余队列不依赖整页异步刷新才能继续操作。
queryClient.setQueryData<ProjectDetail>(["project", detail.project.id], (current) => current ? {
...current,
assetCandidates: current.assetCandidates.map((candidate) => candidate.id === variables.candidateId ? { ...candidate, status: "confirmed", resolvedAssetId: asset.id, updatedAt: asset.updatedAt } : candidate),
assets: current.assets.some((item) => item.id === asset.id) ? current.assets.map((item) => item.id === asset.id ? asset : item) : [asset, ...current.assets],
} : current);
syncPersonalCharacterProjection(asset);
done(variables.targetAssetId ? "候选信息已归并到角色新版本" : "角色卡已创建");
},
onError: failed("角色确认失败"),
});
const confirmingCandidateId = confirmMutation.isPending ? confirmMutation.variables?.candidateId || "" : "";
const saveCharacter = useMutation({
mutationFn: async (values: CharacterForm) => {
const definition = characterDefinition(values);
@@ -139,7 +156,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
<h2 className="text-[18px] font-semibold leading-6">角色与资产</h2>
<span className="rounded bg-foreground/[.055] px-1.5 py-1 text-[10px] font-medium tabular-nums text-foreground/45">{detail.assets.length} 项</span>
<span className="rounded bg-foreground/[.055] px-1.5 py-1 text-[10px] font-medium tabular-nums text-foreground/45">{detail.assets.length} 项已确认</span>
</div>
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-foreground/48">
<span className="inline-flex items-center gap-1.5"><UserRound className="size-3.5" />{characterAssets.length} 个角色</span>
@@ -156,9 +173,34 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
<div className="mt-3 grid gap-3 lg:grid-cols-[156px_minmax(0,1fr)]">
<nav className="space-y-0.5 border-r border-border/70 pr-2" aria-label="资产分类">{categoryCounts.map((item) => <button key={item.value} type="button" onClick={() => setCategory(item.value)} className={`flex h-11 w-full items-center justify-between rounded-md px-2 text-left text-xs ${category === item.value ? "bg-foreground/[.08] font-medium" : "text-foreground/55 hover:bg-foreground/[.04]"}`}><span>{item.value === "all" ? "全部资产" : categoryLabels[item.value]}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[10px] tabular-nums">{item.count}</span></button>)}</nav>
<div className="min-w-0">
{(category === "all" || category === "character") && pendingCandidates.length ? <section className="mb-4"><div className="mb-2 flex items-center gap-1.5 text-xs font-medium"><Sparkles className="size-3.5 text-[var(--workspace-accent)]" />剧情识别出的角色</div><div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">{pendingCandidates.map((candidate) => <article key={candidate.id} className="flex min-h-28 items-center gap-3 rounded-lg border border-dashed border-border p-3"><span className="grid size-12 shrink-0 place-items-center rounded-md bg-foreground/[.045] text-foreground/25"><UserRound className="size-5" /></span><div className="min-w-0 flex-1"><div className="truncate text-xs font-semibold">{candidate.name}</div><div className="mt-1 text-[10px] text-foreground/42">待确认角色卡 · 来自章节分析</div><div className="mt-2 flex min-w-0 items-center gap-1"><Button type="text" size="small" icon={<Check className="size-3.5" />} loading={confirmMutation.isPending} onClick={() => confirmMutation.mutate({ candidateId: candidate.id })}>确认新角色</Button>{characterAssets.length ? <Dropdown trigger={["click"]} menu={{ items: characterAssets.map((asset) => ({ key: asset.id, label: asset.title })), onClick: ({ key }) => confirmMutation.mutate({ candidateId: candidate.id, targetAssetId: key }) }}><Button type="text" size="small" disabled={confirmMutation.isPending}>归并到角色<ChevronDown className="size-3" /></Button></Dropdown> : null}</div></div></article>)}</div></section> : null}
<div className="mb-2 flex items-center justify-between text-xs text-foreground/45"><span>{category === "all" ? "全部资产" : categoryLabel(category)}</span><span>{visibleAssets.length} 项</span></div>
{visibleAssets.length ? <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} generating={generatingAssetIds.has(asset.id)} removing={unlinkMutation.isPending && unlinkMutation.variables === asset.id} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div> : <WorkspaceState icon="assets" compact title="这个分类还没有资产" description={category === "character" ? "新建角色,或先从剧情章节提取角色信息。" : "可从个人素材库引用,或切换到其他分类查看。"} />}
{(category === "all" || category === "character") && pendingCandidates.length ? (
<section className="mb-4" aria-label="待确认角色">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-xs font-medium"><Sparkles className="size-3.5 text-[var(--workspace-accent)]" />剧情识别出的角色</div>
<span className="text-[10px] tabular-nums text-foreground/42">剩余 {pendingCandidates.length} 个待确认</span>
</div>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{pendingCandidates.map((candidate) => {
const confirming = confirmingCandidateId === candidate.id;
return (
<article key={candidate.id} className="flex min-h-28 items-center gap-3 rounded-lg border border-dashed border-border p-3">
<span className="grid size-12 shrink-0 place-items-center rounded-md bg-foreground/[.045] text-foreground/25"><UserRound className="size-5" /></span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold">{candidate.name}</div>
<div className="mt-1 text-[10px] text-foreground/42">待确认角色卡 · 来自章节分析</div>
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1">
<Button type="text" size="small" icon={<Check className="size-3.5" />} loading={confirming} disabled={Boolean(confirmingCandidateId) && !confirming} onClick={() => confirmMutation.mutate({ candidateId: candidate.id })}>确认新角色</Button>
{characterAssets.length ? <Dropdown trigger={["click"]} menu={{ items: characterAssets.map((asset) => ({ key: asset.id, label: asset.title })), onClick: ({ key }) => confirmMutation.mutate({ candidateId: candidate.id, targetAssetId: key }) }}><Button type="text" size="small" disabled={Boolean(confirmingCandidateId)}>归并到角色<ChevronDown className="size-3" /></Button></Dropdown> : null}
</div>
</div>
</article>
);
})}
</div>
</section>
) : null}
<div className="mb-2 flex items-center justify-between text-xs text-foreground/45"><span>{category === "all" ? "全部资产" : categoryLabel(category)}</span><span>{visibleAssets.length} 项已确认</span></div>
{visibleAssets.length ? <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} generating={generatingAssetIds.has(asset.id)} removing={unlinkMutation.isPending && unlinkMutation.variables === asset.id} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div> : (category === "all" || category === "character") && pendingCandidates.length ? null : <WorkspaceState icon="assets" compact title="这个分类还没有资产" description={category === "character" ? "新建角色,或先从剧情章节提取角色信息。" : "可从个人素材库引用,或切换到其他分类查看。"} />}
</div>
</div>

View File

@@ -29,6 +29,7 @@ import {
Link2,
List,
ListOrdered,
LayoutGrid,
Minus,
MoreHorizontal,
MoveVertical,
@@ -51,6 +52,7 @@ import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspa
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 { upsertProjectChapterStoryboard } from "@/lib/canvas/project-chapter-storyboard";
import { navigateToSettings } from "@/lib/settings-navigation";
import {
createProjectAssetCandidates,
@@ -58,11 +60,14 @@ import {
deleteProjectUnit,
getProjectUnit,
importProjectUnits,
linkCanvasUnit,
reorderProjectUnits,
replaceProjectUnitShots,
updateProjectUnit,
type ProjectUnit,
} from "@/services/api/projects";
import { createCanvasProjectWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { formatCount, formatTime, statusLabel, type ProjectDetailViewProps } from "./shared";
@@ -89,8 +94,10 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
const [draftHtml, setDraftHtml] = useState("");
const [dirty, setDirty] = useState(false);
const [analysisKind, setAnalysisKind] = useState<"characters" | "storyboard" | "">("");
const [importingCanvasId, setImportingCanvasId] = useState("");
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const localCanvases = useCanvasStore((state) => state.projects);
const listRef = useRef<HTMLDivElement>(null);
const deferredSearchQuery = useDeferredValue(searchQuery.trim().toLocaleLowerCase("zh-CN"));
const orderedUnits = useMemo(() => {
@@ -106,6 +113,11 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
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 projectCanvasTargets = useMemo(() => {
const targets = new Map(detail.canvases.map((canvas) => [canvas.id, { id: canvas.id, title: canvas.title }]));
localCanvases.filter((canvas) => canvas.projectId === detail.project.id).forEach((canvas) => targets.set(canvas.id, { id: canvas.id, title: canvas.title }));
return Array.from(targets.values());
}, [detail.canvases, detail.project.id, localCanvases]);
const visibleUnits = useMemo(() => {
if (!deferredSearchQuery) return orderedUnits;
const numericQuery = /^\d+$/.test(deferredSearchQuery) ? deferredSearchQuery.replace(/^0+/, "") || "0" : "";
@@ -296,6 +308,43 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
onOk: replaceStoryboard,
});
};
const importStoryboardToCanvas = async (targetCanvasId?: string) => {
if (!selectedUnit) return;
const shots = detail.shots.filter((shot) => shot.unitId === selectedUnit.id);
if (!shots.length) {
message.warning("请先生成本章分镜");
return;
}
setImportingCanvasId(targetCanvasId || "new");
try {
let canvasId = targetCanvasId || "";
if (canvasId) {
const canvas = useCanvasStore.getState().openProject(canvasId);
if (!canvas) throw new Error("目标画布尚未同步到本地,请刷新后重试");
const merged = upsertProjectChapterStoryboard(canvas.nodes, canvas.connections, { unit: selectedUnit, shots });
useCanvasStore.getState().updateProject(canvasId, { nodes: merged.nodes, connections: merged.connections, projectId: detail.project.id });
await saveRemoteUserDataNow();
} else {
const seed = upsertProjectChapterStoryboard([], [], { unit: selectedUnit, shots });
const created = await createCanvasProjectWithRemoteSync(`${selectedUnit.title} · 分镜画布`, detail.project.id, { nodes: seed.nodes, connections: seed.connections });
canvasId = created.id;
if (created.syncError) {
message.warning(created.syncError instanceof Error ? `分镜画布已保存在本地,章节关联稍后重试:${created.syncError.message}` : "分镜画布已保存在本地,章节关联稍后重试");
navigate(`/canvas/${canvasId}`);
return;
}
}
await linkCanvasUnit(detail.project.id, { canvasId, unitId: selectedUnit.id, role: "storyboard" });
refreshProject();
message.success(`已将 ${shots.length} 个分镜导入画布并关联本章`);
navigate(`/canvas/${canvasId}`);
} catch (error) {
refreshProject();
message.error(error instanceof Error ? `分镜导入失败:${error.message}` : "分镜导入失败");
} finally {
setImportingCanvasId("");
}
};
const selectChapter = (unitId: string) => {
if (unitId === selectedId) return;
if (dirty) { message.warning("请先保存当前章节,再切换章节"); return; }
@@ -396,15 +445,16 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
<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">
<header className="flex shrink-0 flex-wrap 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">
<div className="flex shrink-0 flex-wrap items-center justify-end 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>
{chapterShotCount(selectedUnit.id) ? <Dropdown trigger={["click"]} menu={{ items: [{ key: "new", icon: <Plus className="size-3.5" />, label: "新建章节画布并导入" }, ...(projectCanvasTargets.length ? [{ type: "divider" as const }, ...projectCanvasTargets.map((canvas) => ({ key: canvas.id, icon: <LayoutGrid className="size-3.5" />, label: `导入到:${canvas.title}${detail.canvasUnitLinks.some((link) => link.canvasId === canvas.id && link.unitId === selectedUnit.id) ? " · 已关联本章" : ""}` }))] : [])], onClick: ({ key }) => void importStoryboardToCanvas(key === "new" ? undefined : key) }}><Button size="small" type="primary" icon={<LayoutGrid className="size-3.5" />} loading={Boolean(importingCanvasId)} disabled={Boolean(analysisKind)}>导入分镜</Button></Dropdown> : null}
<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>

View File

@@ -82,8 +82,9 @@ export function scheduleRemoteUserDataSync() {
}, 1200);
}
export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string) {
export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial<Pick<CanvasProject, "nodes" | "connections">>) {
const id = useCanvasStore.getState().createProject(title, projectId);
if (initialContent) useCanvasStore.getState().updateProject(id, initialContent);
if (!activeRemoteUserId) return { id, syncError: new Error("尚未建立云端同步会话") };
try {
await saveRemoteUserDataNow();