fix(canvas): 短剧工作流 - 保留角色候选并打通章节分镜入画布
This commit is contained in:
88
web/src/lib/canvas/project-chapter-storyboard.ts
Normal file
88
web/src/lib/canvas/project-chapter-storyboard.ts
Normal 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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user