feat(canvas): 画布工作台 - 新增生成副本并修复大型画布性能、素材同步与删除校验

统一图片与视频副本的生成落点并保留参考连接。\n优化大型画布节点虚拟化、媒体懒加载、拖拽与详情进入路径。\n修复素材主版本 ID 同步及删除时的画布独立引用校验。
This commit is contained in:
ddCat committed 2026-09-01 14:57:05 +08:00
1 parent 434f63e153
commit 3f4a9ea3d0
45 files changed
+1348 -286

No files matched your search

@@ -12,6 +12,9 @@ import { resolveBackendApiUrl } from "@/stores/use-config-store";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { cn } from "@/lib/utils";
type ProjectPreviewMedia = { node: CanvasNodeData; url: string; storageKey?: string };
const projectPreviewMediaCache = new WeakMap<CanvasNodeData[], { first?: ProjectPreviewMedia; latest?: ProjectPreviewMedia }>();
export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; onClick: () => void }) {
return <button type="button" className="app-canvas-create-card" disabled={disabled} onClick={onClick}>
<span className="app-canvas-create-preview"><Plus className="app-canvas-create-icon" /></span>
@@ -106,16 +109,7 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
}
export function ProjectPreview({ project, preferLatestImage = false }: { project: CanvasProject; preferLatestImage?: boolean }) {
const mediaNodes = project.nodes
.flatMap((node) => {
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return [];
const url = getNodeMediaUrl(node);
return isPreviewUrl(url) ? [{ node, url, storageKey: node.metadata?.storageKey }] : [];
});
const imageNodes = mediaNodes.filter(({ node }) => node.type === CanvasNodeType.Image);
const media = preferLatestImage
? imageNodes[imageNodes.length - 1] || mediaNodes[mediaNodes.length - 1]
: imageNodes[0] || mediaNodes[0];
const media = projectPreviewMedia(project.nodes, preferLatestImage);
if (media) {
const { node, url, storageKey } = media;
return (
@@ -145,6 +139,31 @@ export function ProjectPreview({ project, preferLatestImage = false }: { project
);
}
export function projectPreviewMedia(nodes: CanvasNodeData[], preferLatestImage = false) {
let cached = projectPreviewMediaCache.get(nodes);
if (!cached) {
let firstMedia: ProjectPreviewMedia | undefined;
let firstImage: ProjectPreviewMedia | undefined;
let latestMedia: ProjectPreviewMedia | undefined;
let latestImage: ProjectPreviewMedia | undefined;
for (const node of nodes) {
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) continue;
const url = getNodeMediaUrl(node);
if (!isPreviewUrl(url)) continue;
const media = { node, url, storageKey: node.metadata?.storageKey };
firstMedia ||= media;
latestMedia = media;
if (node.type === CanvasNodeType.Image) {
firstImage ||= media;
latestImage = media;
}
}
cached = { first: firstImage || firstMedia, latest: latestImage || latestMedia };
projectPreviewMediaCache.set(nodes, cached);
}
return preferLatestImage ? cached.latest : cached.first;
}
function getNodeMediaUrl(node: CanvasNodeData) {
const resourceId = resourceIdFromStorageKey(node.metadata?.storageKey);
if (resourceId) return resourceFileUrl(resourceId);