feat(assets): 素材库与画布 - 新增可交互通用文件夹

This commit is contained in:
echoD886 committed 2026-08-20 15:16:45 +08:00
1 parent 0e251c3c8f
commit 5d775071e4
35 files changed
+2010 -155

No files matched your search

+87 -4
View File
@@ -17,6 +17,7 @@ import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-charac
import { shouldAutoConnectCanvasRuntime } from "@/lib/canvas/local-runtime-connection";
import { useAssetStore } from "@/stores/use-asset-store";
import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { App } from "antd";
@@ -108,6 +109,7 @@ import {
CanvasNodeType,
type CanvasAssistantSession,
type CanvasConnection,
type CanvasFolderStyle,
type CanvasNodeData,
type CanvasMediaPerformanceMode,
type StoryboardColumn,
@@ -200,6 +202,7 @@ function InfiniteCanvasPage() {
const [stylePickerOpen, setStylePickerOpen] = useState(false);
const [projectAssetOpen, setProjectAssetOpen] = useState(false);
const [projectAssetInitialCategory, setProjectAssetInitialCategory] = useState("all");
const [projectAssetInitialFolderId, setProjectAssetInitialFolderId] = useState("all");
const [projectAssetInsertPosition, setProjectAssetInsertPosition] = useState<Position | undefined>();
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
const [subtitleNodeId, setSubtitleNodeId] = useState<string | null>(null);
@@ -334,6 +337,23 @@ function InfiniteCanvasPage() {
const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : "";
const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) });
const refetchLinkedProject = linkedProjectQuery.refetch;
const archiveNodesToLinkedFolder = useCallback((folder: CanvasNodeData, droppedNodes: CanvasNodeData[]) => {
const folderId = folder.metadata?.folder?.assetFolderId;
const domainProjectId = folder.metadata?.folder?.projectId || linkedProjectId;
if (!folderId || !domainProjectId || !droppedNodes.length) return;
void Promise.all(droppedNodes.map((node) => ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, folderId, node, source: "canvas-manual" })))
.then((results) => {
const archivedByNodeId = new Map(droppedNodes.map((node, index) => [node.id, { assetId: results[index].assetId, content: node.metadata?.content, previousAssetId: node.metadata?.assetId }]));
setNodes((current) => current.map((node) => {
const archived = archivedByNodeId.get(node.id);
if (!archived || node.metadata?.content !== archived.content || node.metadata?.assetId !== archived.previousAssetId) return node;
return { ...node, metadata: { ...node.metadata, assetId: archived.assetId } };
}));
void refetchLinkedProject();
message.success(`已归档到“${folder.title}”`);
})
.catch((error) => message.error(error instanceof Error ? error.message : "素材归档失败"));
}, [linkedProjectId, message, projectId, refetchLinkedProject, setNodes]);
useEffect(() => {
if (!projectLoaded || !linkedProjectQuery.data) return;
setNodes((current) => refreshCanvasCharacterReferenceNodes(current, linkedProjectQuery.data.assets));
@@ -621,9 +641,10 @@ function InfiniteCanvasPage() {
);
const openProjectAssets = useCallback(
(initialCategory = "all", position?: Position, scope: "canvas" | "timeline" = "canvas") => {
(initialCategory = "all", position?: Position, scope: "canvas" | "timeline" = "canvas", initialFolderId = "all") => {
setProjectAssetScope(scope);
setProjectAssetInitialCategory(initialCategory);
setProjectAssetInitialFolderId(initialFolderId);
setProjectAssetInsertPosition(position);
setProjectAssetOpen(true);
// 资产与项目实时同步:打开弹窗前刷新关联短剧项目资产,避免缓存导致资产列表空白/过期。
@@ -647,6 +668,7 @@ function InfiniteCanvasPage() {
const closeProjectAssets = useCallback(() => {
setProjectAssetOpen(false);
setProjectAssetInsertPosition(undefined);
setProjectAssetInitialFolderId("all");
}, []);
const {
@@ -749,6 +771,7 @@ function InfiniteCanvasPage() {
arrangeSelectedNodes,
copyNodesToClipboard,
copySelectedNodes,
createFolder,
createNode,
createReferenceGroup,
createStoryboardGroup,
@@ -863,6 +886,7 @@ function InfiniteCanvasPage() {
onNodeInteractionStart: handleNodeInteractionStart,
onNodeClick: handleSelectedNodeClick,
onBatchConnectionTarget: handleBatchConnectionTargetClick,
onLinkedFolderDrop: archiveNodesToLinkedFolder,
onDeselect: handleCanvasDeselect,
onSelectionBoxEnd: () => setCanvasTool((tool) => (tool === "box-select" ? "move" : tool)),
});
@@ -891,6 +915,7 @@ function InfiniteCanvasPage() {
collapsingBatchIds,
downloadNodeImage,
handleConfigNodeChange,
handleFolderStyleChange,
handleFontSizeChange,
handleNodeContentChange,
handleNodePromptChange,
@@ -914,6 +939,60 @@ function InfiniteCanvasPage() {
setHoveredNodeId,
});
const handleProjectFolderInsert = useCallback((folderId: string) => {
const folder = linkedProjectQuery.data?.assetFolders.find((item) => item.id === folderId);
if (!folder || !linkedProjectId) throw new Error("素材文件夹已不存在,请刷新后重试");
const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass";
createFolder(projectAssetInsertPosition, { id: folder.id, projectId: linkedProjectId, title: folder.name, style, createdAt: folder.createdAt });
}, [createFolder, linkedProjectId, linkedProjectQuery.data?.assetFolders, projectAssetInsertPosition]);
const handleFrameToggle = useCallback((nodeId: string) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
const linkedFolderId = node?.metadata?.folder?.assetFolderId;
if (linkedFolderId) {
openProjectAssets("all", node ? { x: node.position.x + node.width + 40, y: node.position.y } : undefined, "canvas", linkedFolderId);
return;
}
toggleFrameCollapsed(nodeId);
}, [nodesRef, openProjectAssets, toggleFrameCollapsed]);
const linkedFolderPreviewNodesById = useMemo(() => {
const result = new Map<string, CanvasNodeData[]>();
const localById = new Map(assets.map((asset) => [asset.id, asset]));
for (const asset of linkedProjectQuery.data?.assets || []) {
if (!asset.folderId) continue;
const local = localById.get(asset.id);
const characterCover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
const type = asset.category === "character" || asset.mediaType === "image" ? CanvasNodeType.Image : asset.mediaType === "video" ? CanvasNodeType.Video : asset.mediaType === "audio" ? CanvasNodeType.Audio : CanvasNodeType.Text;
const remoteResourceId = resourceIdFromStorageKey(asset.storageKey);
const content = characterCover ? resourceFileUrl(characterCover.resourceId) : local?.kind === "image" ? local.data.dataUrl || local.coverUrl : local?.kind === "video" || local?.kind === "audio" ? local.data.url : local?.kind === "text" ? local.data.content : remoteResourceId ? resourceFileUrl(remoteResourceId) : asset.previewText || "";
const preview: CanvasNodeData = { id: asset.id, type, title: asset.title, position: { x: 0, y: 0 }, width: 240, height: 160, metadata: { assetId: asset.id, content } };
const current = result.get(asset.folderId) || [];
current.push(preview);
result.set(asset.folderId, current);
}
return result;
}, [assets, linkedProjectQuery.data?.assets]);
useEffect(() => {
const folders = linkedProjectQuery.data?.assetFolders;
if (!folders?.length) return;
const byId = new Map(folders.map((folder) => [folder.id, folder]));
setNodes((current) => {
let changed = false;
const next = current.map((node) => {
const folderId = node.metadata?.folder?.assetFolderId;
const folder = folderId ? byId.get(folderId) : undefined;
if (!folder) return node;
const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass";
if (node.title === folder.name && node.metadata?.folder?.style === style) return node;
changed = true;
return { ...node, title: folder.name, metadata: { ...node.metadata, folder: { ...node.metadata!.folder!, style } } };
});
return changed ? next : current;
});
}, [linkedProjectQuery.data?.assetFolders, setNodes]);
const {
activeDirectorScene,
activeNodeId,
@@ -1741,6 +1820,7 @@ function InfiniteCanvasPage() {
nodeById={nodeById}
visibleNodes={visibleNodes}
frameChildrenById={frameChildrenById}
linkedFolderPreviewNodesById={linkedFolderPreviewNodesById}
dragPreview={dragPreview}
selectedNodeIds={selectedNodeIds}
frameDropTargetId={frameDropTargetId}
@@ -1778,7 +1858,8 @@ function InfiniteCanvasPage() {
onNodeHoverEnd={handleCanvasNodeHoverEnd}
onConnectStart={handleConnectStart}
onNodeResize={handleNodeResize}
onToggleFrame={toggleFrameCollapsed}
onToggleFrame={handleFrameToggle}
onFolderStyleChange={handleFolderStyleChange}
onNodeTitleChange={handleNodeTitleChange}
onNodeContextMenu={handleNodeContextMenu}
onNodeContentChange={handleNodeContentChange}
@@ -1835,6 +1916,7 @@ function InfiniteCanvasPage() {
onChooseStyle={() => setStylePickerOpen(true)}
onAddScript={() => createNode(CanvasNodeType.Script)}
onAddFrame={() => createNode(CanvasNodeType.Frame)}
onAddFolder={createFolder}
onAddDrawing={() => createNode(CanvasNodeType.Drawing)}
onOpenDirector={() => createDirectorShot()}
onUndo={undoCanvas}
@@ -2045,6 +2127,7 @@ function InfiniteCanvasPage() {
screenToCanvas={screenToCanvas}
onClose={() => setContextMenu(null)}
onAddNode={(type, position) => createNode(type, position)}
onAddFolder={createFolder}
onChooseStyle={() => setStylePickerOpen(true)}
onOpenDirector={createDirectorShot}
onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)}
@@ -2071,7 +2154,7 @@ function InfiniteCanvasPage() {
void copyNodeMediaUrlToClipboard(node);
}}
onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })}
onToggleFrame={(node) => toggleFrameCollapsed(node.id)}
onToggleFrame={(node) => handleFrameToggle(node.id)}
/>
<CanvasUploadModal open={uploadModalOpen} onClose={closeUploadModal} onUpload={handleUploadFiles} />
@@ -2276,7 +2359,7 @@ function InfiniteCanvasPage() {
/>
<AssetPickerModal open={assetPickerOpen} onInsert={handleTimelineAssetInsert} onClose={closeAssetPicker} />
<CanvasProjectAssetModal open={projectAssetOpen} detail={linkedProjectQuery.data} initialCategory={projectAssetInitialCategory} onClose={closeProjectAssets} onInsert={handleTimelineProjectAssetsInsert} />
<CanvasProjectAssetModal open={projectAssetOpen} detail={linkedProjectQuery.data} initialCategory={projectAssetInitialCategory} initialFolderId={projectAssetInitialFolderId} onClose={closeProjectAssets} onInsert={handleTimelineProjectAssetsInsert} onInsertFolder={projectAssetScope === "canvas" ? handleProjectFolderInsert : undefined} />
{codexCompactAgent && !assistantMounted ? (
<CanvasLocalAgentPanel headless snapshot={agentSnapshot} canUndoOps={canUndoAgentOps} undoOpsCount={agentUndoCount} onApplyOps={applyAgentOps} onUndoOps={undoAgentOps} autoConnect={codexAutoConnect} />
) : null}