diff --git a/web/src/components/cached-resource-image.tsx b/web/src/components/cached-resource-image.tsx index efda66929..f30b29853 100644 --- a/web/src/components/cached-resource-image.tsx +++ b/web/src/components/cached-resource-image.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ImgHTMLAttributes, type ReactNode } f import { resourceIdFromStorageKey } from "@/services/api/resources"; import { cacheResourceObjectUrl } from "@/services/resource-blob-cache"; +import { resolveImageUrl } from "@/services/image-storage"; type CachedResourceImageProps = Omit, "src"> & { storageKey?: string; @@ -12,10 +13,11 @@ type CachedResourceImageProps = Omit, "src"> /** * 资源图片优先读取按用户隔离的本地 Blob 缓存,避免刷新后再次从对象存储下载。 - * 普通外链、data URL 和本地 Blob URL 不经过资源缓存,保持原有行为。 + * 本地 image: 类型的 storageKey 也会自动从 LocalForage 恢复有效的 Object URL。 */ export function CachedResourceImage({ storageKey, src = "", fallback = null, eager = false, onError, ...props }: CachedResourceImageProps) { const remoteResource = Boolean(resourceIdFromStorageKey(storageKey)); + const localImageResource = Boolean(storageKey && storageKey.startsWith("image:")); const targetRef = useRef(null); const [nearViewport, setNearViewport] = useState(eager || !remoteResource); const [cachedSrc, setCachedSrc] = useState(remoteResource ? "" : src); @@ -31,12 +33,15 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, eag setNearViewport(true); return; } - const observer = new IntersectionObserver((entries) => { - if (entries.some((entry) => entry.isIntersecting)) { - setNearViewport(true); - observer.disconnect(); - } - }, { rootMargin: "240px" }); + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setNearViewport(true); + observer.disconnect(); + } + }, + { rootMargin: "240px" }, + ); observer.observe(image); return () => observer.disconnect(); }, [eager, remoteResource]); @@ -44,32 +49,78 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, eag useEffect(() => { let cancelled = false; setCacheFailed(false); - if (!remoteResource || !storageKey) { - setCachedSrc(src); - return () => { cancelled = true; }; - } - if (!nearViewport) { - setCachedSrc(""); - return () => { cancelled = true; }; - } - setCachedSrc(""); - const resolve = cacheResourceObjectUrl(storageKey); - void resolve.then((url) => { - if (!cancelled) setCachedSrc(url || src); - }).catch(() => { - if (!cancelled) { - setCacheFailed(true); - setCachedSrc(src); + if (remoteResource && storageKey) { + if (!nearViewport) { + setCachedSrc(""); + return () => { + cancelled = true; + }; } - }); - return () => { cancelled = true; }; - }, [nearViewport, remoteResource, src, storageKey]); + setCachedSrc(""); + const resolve = cacheResourceObjectUrl(storageKey); + void resolve + .then((url) => { + if (!cancelled) setCachedSrc(url || src); + }) + .catch(() => { + if (!cancelled) { + setCacheFailed(true); + setCachedSrc(src); + } + }); + return () => { + cancelled = true; + }; + } - if (!remoteResource) return ; + if (localImageResource && storageKey) { + void resolveImageUrl(storageKey, src) + .then((url) => { + if (!cancelled) setCachedSrc(url || src); + }) + .catch(() => { + if (!cancelled) setCachedSrc(src); + }); + return () => { + cancelled = true; + }; + } + + setCachedSrc(src); + return () => { + cancelled = true; + }; + }, [localImageResource, nearViewport, remoteResource, src, storageKey]); + + const handleImgError = (e: React.SyntheticEvent) => { + if (localImageResource && storageKey && cachedSrc.startsWith("blob:")) { + void resolveImageUrl(storageKey) + .then((url) => { + if (url && url !== cachedSrc) { + setCachedSrc(url); + return; + } + setCacheFailed(true); + onError?.(e); + }) + .catch(() => { + setCacheFailed(true); + onError?.(e); + }); + return; + } + setCacheFailed(true); + onError?.(e); + }; + + if (!remoteResource) { + if (cacheFailed && fallback) return <>{fallback}; + return ; + } return ( - {cachedSrc && !(cacheFailed && !src) ? : fallback} + {cachedSrc && !(cacheFailed && !src) ? : fallback} ); } diff --git a/web/src/components/canvas/canvas-project-card.tsx b/web/src/components/canvas/canvas-project-card.tsx index a37cf992c..b3dedb2a1 100644 --- a/web/src/components/canvas/canvas-project-card.tsx +++ b/web/src/components/canvas/canvas-project-card.tsx @@ -1,4 +1,4 @@ -import { Check, Clapperboard, Download, FileText, Frame, Image as ImageIcon, MoreHorizontal, Music2, Pencil, Plus, Settings2, Sparkles, Trash2, Video, X } from "lucide-react"; +import { Check, Clapperboard, CloudUpload, Download, FileText, Frame, Image as ImageIcon, MoreHorizontal, Music2, Pencil, Plus, Settings2, Sparkles, Trash2, Video, X } from "lucide-react"; import type { ReactNode } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { Dropdown, Input } from "antd"; @@ -11,16 +11,21 @@ import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resour import { resolveBackendApiUrl } from "@/stores/use-config-store"; import { CachedResourceImage } from "@/components/cached-resource-image"; import { cn } from "@/lib/utils"; +import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; type ProjectPreviewMedia = { node: CanvasNodeData; url: string; storageKey?: string }; const projectPreviewMediaCache = new WeakMap(); export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; onClick: () => void }) { - return ; + return ( + + ); } export function CanvasProjectCard({ project, projectName, variant = "library", readOnly = false, footer }: { project: CanvasProject; projectName?: string; variant?: "library" | "recent"; readOnly?: boolean; footer?: ReactNode }) { @@ -57,14 +62,27 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r > - {!compact && !readOnly ? event.stopPropagation()}> toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox" aria-label={`选择 ${project.title}`} /> : null} - + {!compact && !readOnly ? ( + event.stopPropagation()}> + toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox" aria-label={`选择 ${project.title}`} /> + + ) : null} +
{editing && !readOnly ? ( - event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus /> + event.stopPropagation()} + onChange={(event) => setEditingTitle(event.target.value)} + onKeyDown={(event) => event.key === "Enter" && saveTitle()} + autoFocus + /> ) : ( - + +
) : !readOnly ? (
event.stopPropagation()}> - + - +
) : null}
-
{projectName || "自由画布"}
- {footer ?
event.stopPropagation()}>{footer}
: null} +
+ {projectName || "自由画布"} + + +
+ {footer ? ( +
event.stopPropagation()}> + {footer} +
+ ) : null} ); } export function ProjectPreview({ project, preferLatestImage = false }: { project: CanvasProject; preferLatestImage?: boolean }) { + const syncProgress = useSyncProgressStore((state) => state.syncingProjects[project.id]); + const isSyncing = Boolean(syncProgress && (syncProgress.phase === "uploading" || syncProgress.phase === "saving")); const media = projectPreviewMedia(project.nodes, preferLatestImage); - if (media) { - const { node, url, storageKey } = media; - return ( -
- {node.type === CanvasNodeType.Video - ?
- : } -
- ); - } - const nodes = project.nodes.slice(0, 8); - if (!nodes.length) return
空白画布等待第一幕
; - const previewNodes = buildNodePreviewLayout(nodes); - return ( + const content = media ? ( +
+ {media.node.type === CanvasNodeType.Video ? ( +
+
+ ) : ( + + )} +
+ ) : !project.nodes.length ? ( +
+ + 空白画布 + 等待第一幕 +
+ ) : (
- {previewNodes.map(({ node, style }) => { + {buildNodePreviewLayout(project.nodes.slice(0, 8)).map(({ node, style }) => { const presentation = getNodePresentation(node); return ( @@ -137,6 +176,43 @@ export function ProjectPreview({ project, preferLatestImage = false }: { project })}
); + + return ( +
+ {content} + {isSyncing && syncProgress ? ( +
e.stopPropagation()}> +
+ + 云端同步中 +
+
+ {syncProgress.total > 0 ? ( + <> +
+ 媒体上传 + + {syncProgress.completed}/{syncProgress.total} + +
+
+
+
+ + ) : ( +
正在写入云端结构...
+ )} +
请勿关闭或刷新浏览器
+
+
+ ) : null} +
+ ); } export function projectPreviewMedia(nodes: CanvasNodeData[], preferLatestImage = false) { @@ -210,7 +286,7 @@ function getNodePresentation(node: CanvasNodeData) { } function isPreviewUrl(value?: string) { - return Boolean(value && (/^(https?:|blob:|data:image\/|data:video\/|\/api\/)/.test(value))); + return Boolean(value && /^(https?:|blob:|data:image\/|data:video\/|\/api\/)/.test(value)); } export function formatProjectTime(value: string) { diff --git a/web/src/pages/canvas/index.tsx b/web/src/pages/canvas/index.tsx index 1d2d21ff8..0157fe2ee 100644 --- a/web/src/pages/canvas/index.tsx +++ b/web/src/pages/canvas/index.tsx @@ -13,13 +13,17 @@ import { setImageBlob } from "@/services/image-storage"; import { CanvasCreateCard } from "@/components/canvas/canvas-project-card"; import { CanvasFolderCard } from "@/components/canvas/canvas-folder-card"; import type { CanvasExportFile } from "@/types/canvas-export"; -import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; +import type { CanvasNodeData } from "@/types/canvas"; +import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store"; import { exportCanvasProjects } from "@/lib/canvas/canvas-export"; import { saveCanvasDrawing, type CanvasDrawingRenderDraft } from "@/lib/canvas/canvas-drawing-storage"; -import { createCanvasProjectWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync"; +import { createCanvasProjectWithRemoteSync, hasRemoteUserDataSyncSession, saveRemoteUserDataNow, scheduleRemoteUserDataSync } from "@/services/user-data-sync"; import { listProjects } from "@/services/api/projects"; import { loadCanvasProjectPage } from "@/lib/workspace-route-modules"; +import { resourceFileUrl, resourceStorageKey, uploadResourceFile } from "@/services/api/resources"; +import { primeResourceBlobCache } from "@/services/resource-blob-cache"; +import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; export default function CanvasPage() { const { message } = App.useApp(); @@ -51,13 +55,16 @@ export default function CanvasPage() { const preloadProject = useCallback(() => { void loadCanvasProjectPage(); }, []); - const enterProject = useCallback((id: string) => { - if (openingProjectIdRef.current) return; - openingProjectIdRef.current = id; - setOpeningProjectId(id); - preloadProject(); - window.requestAnimationFrame(() => navigate(`/canvas/${id}${forwardedQuery}`)); - }, [forwardedQuery, navigate, preloadProject]); + const enterProject = useCallback( + (id: string) => { + if (openingProjectIdRef.current) return; + openingProjectIdRef.current = id; + setOpeningProjectId(id); + preloadProject(); + window.requestAnimationFrame(() => navigate(`/canvas/${id}${forwardedQuery}`)); + }, + [forwardedQuery, navigate, preloadProject], + ); const createAndEnter = () => { void createCanvasProjectWithRemoteSync(`自由画布 ${projects.length + 1}`).then(({ id, syncError }) => { if (syncError) message.warning(syncError instanceof Error ? `画布已在本地创建,云端同步失败:${syncError.message}` : "画布已在本地创建,云端同步失败"); @@ -111,30 +118,95 @@ export default function CanvasPage() { }; const importCanvas = async (file?: File) => { if (!file) return; + const hideLoading = message.loading({ content: "正在解压并准备导入画布...", duration: 0 }); try { const zip = await readZip(file); const projectFile = zip.get("projects.json"); - if (!projectFile) throw new Error("missing projects.json"); + if (!projectFile) throw new Error("缺少 projects.json 元数据文件"); const data = JSON.parse(await projectFile.text()) as CanvasExportFile; - await Promise.all( - data.projects.flatMap((project) => - project.files.map(async (item) => { - const blob = zip.get(item.path); - if (!blob) return; - const typedBlob = blob.type ? blob : blob.slice(0, blob.size, item.mimeType); - await (item.storageKey.startsWith("image:") ? setImageBlob(item.storageKey, typedBlob) : setMediaBlob(item.storageKey, typedBlob)); - }), - ), - ); - await Promise.all( - data.projects.map(async (item) => { - const drawingEngineById = new Map((item.drawingDocuments || []).map((document) => [document.drawingId, document.engine || "tldraw"])); - const importedProjectId = importProject({ - ...item.project, - nodes: item.project.nodes.map((node) => - node.type === "drawing" && node.metadata?.drawingId ? { ...node, metadata: { ...node.metadata, drawingEngine: drawingEngineById.get(node.metadata.drawingId) || node.metadata.drawingEngine || "tldraw" } } : node, - ), + if (!Array.isArray(data.projects)) throw new Error("projects.json 中缺少画布列表"); + for (const item of data.projects) { + if (!Array.isArray(item.files)) throw new Error(`画布「${item.project?.title || "未命名画布"}」的媒体清单无效`); + const missing = item.files.find((entry) => !zip.get(entry.path)); + if (missing) throw new Error(`压缩包缺少媒体文件:${missing.path}`); + } + hideLoading(); + const remoteSyncEnabled = hasRemoteUserDataSyncSession(); + let remoteSyncWarning: unknown; + + for (const item of data.projects) { + const totalFiles = item.files.length; + const importedProjectId = importProject({ + ...item.project, + title: item.project.title || "导入画布", + nodes: item.project.nodes || [], + }); + + if (totalFiles > 0) { + useSyncProgressStore.getState().setProjectProgress(importedProjectId, { + projectId: importedProjectId, + total: totalFiles, + completed: 0, + phase: "uploading", + message: "正在上传媒体至云端", }); + } + + try { + const storageKeyMap = new Map(); + const concurrency = 4; + let fileIndex = 0; + const workers = new Array(Math.min(item.files.length, concurrency)).fill(null).map(async () => { + while (fileIndex < item.files.length) { + const current = fileIndex++; + const fileItem = item.files[current]; + const blob = zip.get(fileItem.path)!; + const mime = fileItem.mimeType || blob.type || "image/png"; + const typedBlob = blob.type ? blob : blob.slice(0, blob.size, mime); + const kind: "image" | "video" | "audio" | "file" = mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : mime.startsWith("audio/") ? "audio" : "file"; + + try { + const resource = await uploadResourceFile(typedBlob, kind, { fileName: fileItem.path.split("/").pop() }); + const newStorageKey = resourceStorageKey(resource.id); + const newUrl = resourceFileUrl(resource.id); + await primeResourceBlobCache(newStorageKey, typedBlob).catch(() => ""); + storageKeyMap.set(fileItem.storageKey, { storageKey: newStorageKey, url: newUrl }); + } catch (uploadErr) { + console.warn("上传资源到后端失败,降级保存本地", uploadErr); + const localUrl = await (fileItem.storageKey.startsWith("image:") ? setImageBlob(fileItem.storageKey, typedBlob) : setMediaBlob(fileItem.storageKey, typedBlob)); + if (localUrl) { + storageKeyMap.set(fileItem.storageKey, { storageKey: fileItem.storageKey, url: localUrl }); + } + } finally { + useSyncProgressStore.getState().incrementProjectCompleted(importedProjectId); + } + } + }); + await Promise.all(workers); + + const drawingEngineById = new Map((item.drawingDocuments || []).map((document) => [document.drawingId, document.engine || "tldraw"])); + const remapNodeMedia = (node: CanvasNodeData): CanvasNodeData => { + const oldKey = node.metadata?.storageKey; + const mapped = oldKey ? storageKeyMap.get(oldKey) : undefined; + const isDeadBlob = (val?: string) => typeof val === "string" && val.startsWith("blob:"); + const nextStorageKey = mapped ? mapped.storageKey : oldKey && !isDeadBlob(oldKey) ? oldKey : undefined; + const content = mapped ? mapped.url : isDeadBlob(node.metadata?.content) ? "" : node.metadata?.content; + const previewContent = mapped ? mapped.url : isDeadBlob(node.metadata?.previewContent) ? "" : node.metadata?.previewContent; + return { + ...node, + metadata: { + ...node.metadata, + ...(nextStorageKey !== undefined ? { storageKey: nextStorageKey } : {}), + ...(content !== undefined ? { content } : {}), + ...(previewContent !== undefined ? { previewContent } : {}), + drawingEngine: node.type === "drawing" && node.metadata?.drawingId ? drawingEngineById.get(node.metadata.drawingId) || node.metadata.drawingEngine || "tldraw" : node.metadata?.drawingEngine, + }, + }; + }; + + const remappedNodes = (item.project.nodes || []).map(remapNodeMedia); + updateProject(importedProjectId, { nodes: remappedNodes }); + await Promise.all( (item.drawingDocuments || []).map((document) => { const previewFile = document.previewPath ? zip.get(document.previewPath) : undefined; @@ -172,11 +244,36 @@ export default function CanvasPage() { ); }), ); - }), - ); - message.success(`已导入 ${data.projects.length} 个画布`); - } catch { - message.error("导入失败,请选择有效的画布压缩包"); + + useSyncProgressStore.getState().setProjectProgress(importedProjectId, { + phase: "saving", + message: remoteSyncEnabled ? "正在保存画布结构" : "正在保存本地画布", + }); + await flushCanvasStorePersistence(); + if (remoteSyncEnabled) { + try { + await saveRemoteUserDataNow(); + } catch (syncError) { + remoteSyncWarning ||= syncError; + scheduleRemoteUserDataSync(); + console.warn("导入画布云端同步失败,等待自动重试", syncError); + } + } + } finally { + useSyncProgressStore.getState().setProjectProgress(importedProjectId, null); + } + } + + await flushCanvasStorePersistence(); + if (remoteSyncWarning) { + message.warning(`已导入 ${data.projects.length} 个画布,云端同步未完成,将自动重试`); + } else { + message.success(remoteSyncEnabled ? `已导入 ${data.projects.length} 个画布并完成云端同步` : `已导入 ${data.projects.length} 个画布并保存到本地`); + } + } catch (error) { + hideLoading(); + console.error("导入画布失败", error); + message.error(error instanceof Error ? `导入失败:${error.message}` : "导入失败,请选择有效的画布压缩包"); } finally { if (inputRef.current) inputRef.current.value = ""; } @@ -360,9 +457,11 @@ export default function CanvasPage() { ) : ( )} - {hydrated && visibleProjects.length ?
- {visibleProjects.length < filteredProjects.length ? `继续下滑加载更多(每页 50 条)` : `已加载全部 ${filteredProjects.length} 个画布`} -
: null} + {hydrated && visibleProjects.length ? ( +
+ {visibleProjects.length < filteredProjects.length ? `继续下滑加载更多(每页 50 条)` : `已加载全部 ${filteredProjects.length} 个画布`} +
+ ) : null}
void importCanvas(event.target.files?.[0])} /> diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index b26f63988..e59a5cef8 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -7,6 +7,7 @@ import type { Asset } from "@/stores/use-asset-store"; import { flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store"; import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store"; +import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; let activeRemoteUserId = ""; type RemoteUserDataPhase = "inactive" | "hydrating" | "ready" | "failed"; @@ -72,6 +73,11 @@ export function resetRemoteUserDataSync() { syncTimer = null; } syncQueued = false; + useSyncProgressStore.getState().clearAll(); +} + +export function hasRemoteUserDataSyncSession() { + return Boolean(activeRemoteUserId) && remoteUserDataPhase === "ready"; } export function withRemoteUserDataSyncExclusive(operation: () => Promise): Promise { @@ -137,6 +143,34 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) { // 批量删除允许部分成功;每个已成功远端删除的实体都立即落实到本地 durable cache。 await flushCanvasStorePersistence(); } + // 清理仅属于被删除画布的自动生成素材 + const currentAssets = useAssetStore.getState().assets; + const remainingProjects = useCanvasStore.getState().projects; + const activeAssetIds = new Set(); + for (const proj of remainingProjects) { + for (const node of proj.nodes) { + if (node.metadata?.assetId) activeAssetIds.add(node.metadata.assetId); + } + } + const assetsToRemove = currentAssets.filter((asset) => { + const canvasId = asset.metadata?.canvasId as string | undefined; + const source = asset.metadata?.source as string | undefined; + return canvasId && projectIds.includes(canvasId) && source === "canvas-generation" && !activeAssetIds.has(asset.id); + }); + for (const asset of assetsToRemove) { + if (activeRemoteUserId) { + try { + await deleteRemoteAsset(asset.id); + acknowledgedAssets.delete(asset.id); + } catch { + // 忽略远端删除失败,继续本地清理 + } + } + await useAssetStore.getState().removeAsset(asset.id); + } + if (assetsToRemove.length > 0) { + await flushAssetStorePersistence(); + } }); } @@ -180,9 +214,42 @@ async function saveRemoteUserDataBatch() { // 转换后的 resource: 引用只属于发往服务端的 payload,不能反写整份实时 store。 // 已确认快照记录的是本次上传所依据的本地实体;上传期间的新编辑会在下一轮继续提交。 for (const source of dirtyProjects) { - const remotePayload = await ensureRemoteResourceReferences(source, uploaded); - await upsertRemoteCanvasProject(remotePayload); - acknowledgedProjects.set(source.id, source); + const keysToUpload = collectLocalMediaKeys(source); + const total = keysToUpload.length; + if (total > 0) { + useSyncProgressStore.getState().setProjectProgress(source.id, { + projectId: source.id, + total, + completed: 0, + phase: "uploading", + message: "正在同步媒体至云端", + }); + } + const onMediaUploaded = () => { + if (total > 0) { + useSyncProgressStore.getState().incrementProjectCompleted(source.id); + } + }; + try { + const remotePayload = await ensureRemoteResourceReferences(source, uploaded, onMediaUploaded); + if (total > 0) { + useSyncProgressStore.getState().setProjectProgress(source.id, { + phase: "saving", + message: "正在保存画布结构", + }); + } + await upsertRemoteCanvasProject(remotePayload); + acknowledgedProjects.set(source.id, source); + if (total > 0) useSyncProgressStore.getState().setProjectProgress(source.id, null); + } catch (error) { + if (total > 0) { + useSyncProgressStore.getState().setProjectProgress(source.id, { + phase: "error", + message: error instanceof Error ? error.message : "云端同步失败,等待重试", + }); + } + throw error; + } } for (const source of dirtyAssets) { const remotePayload = await ensureRemoteResourceReferences(assetForRemoteSync(source), uploaded); @@ -199,17 +266,37 @@ async function saveRemoteUserDataBatch() { } } -async function ensureRemoteResourceReferences(value: T, uploaded = new Map()): Promise { +function collectLocalMediaKeys(value: unknown, set = new Set()): string[] { + if (!value || typeof value !== "object") return [...set]; + if (Array.isArray(value)) { + for (const item of value) collectLocalMediaKeys(item, set); + return [...set]; + } + const record = value as Record; + const storageKey = typeof record.storageKey === "string" ? record.storageKey : ""; + if (isLocalStorageKey(storageKey) && !resourceIdFromStorageKey(storageKey)) { + set.add(storageKey); + } else { + const inline = inlineMediaDataUrl(record); + if (inline) set.add(`${inline.length}:${inline.slice(0, 64)}:${inline.slice(-64)}`); + } + for (const child of Object.values(record)) { + collectLocalMediaKeys(child, set); + } + return [...set]; +} + +async function ensureRemoteResourceReferences(value: T, uploaded = new Map(), onUploaded?: () => void): Promise { if (!value || typeof value !== "object") return value; if (Array.isArray(value)) { const result: unknown[] = []; - for (const item of value) result.push(await ensureRemoteResourceReferences(item, uploaded)); + for (const item of value) result.push(await ensureRemoteResourceReferences(item, uploaded, onUploaded)); return result as T; } const next: Record = {}; for (const [key, child] of Object.entries(value)) { - next[key] = await ensureRemoteResourceReferences(child, uploaded); + next[key] = await ensureRemoteResourceReferences(child, uploaded, onUploaded); } const storageKey = typeof next.storageKey === "string" ? next.storageKey : ""; @@ -220,17 +307,24 @@ async function ensureRemoteResourceReferences(value: T, uploaded = new Map, storageKey: string) { - const url = resourceFileUrl(storageKey.slice("resource:".length)); + const resourceId = resourceIdFromStorageKey(storageKey); + if (!resourceId) { + throw new Error(`远端资源引用无效:${storageKey}`); + } + const url = resourceFileUrl(resourceId); payload.storageKey = storageKey; for (const key of ["content", "dataUrl", "url", "coverUrl"]) { if (typeof payload[key] === "string") payload[key] = url; @@ -257,7 +351,7 @@ async function uploadInlineDataUrl(dataUrl: string) { async function uploadLocalStorageKey(storageKey: string, payload: Record) { const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey); - if (!blob) throw new Error(`本地媒体不存在:${storageKey}`); + if (!blob) throw new Error(`本地媒体不存在,无法同步:${storageKey}`); const kind = blob.type.startsWith("image/") ? "image" : blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file"; const resource = await uploadResourceFile(blob, kind, { width: numberValue(payload.naturalWidth) || numberValue(payload.width), diff --git a/web/src/stores/use-sync-progress-store.ts b/web/src/stores/use-sync-progress-store.ts new file mode 100644 index 000000000..f4e366b7a --- /dev/null +++ b/web/src/stores/use-sync-progress-store.ts @@ -0,0 +1,70 @@ +import { create } from "zustand"; + +export type SyncProjectProgress = { + projectId: string; + total: number; + completed: number; + phase: "uploading" | "saving" | "done" | "error"; + message?: string; +}; + +type SyncProgressStore = { + syncingProjects: Record; + setProjectProgress: (projectId: string, patch: Partial | null) => void; + incrementProjectCompleted: (projectId: string) => void; + clearAll: () => void; + isAnySyncing: () => boolean; +}; + +export const useSyncProgressStore = create((set, get) => ({ + syncingProjects: {}, + setProjectProgress: (projectId, patch) => + set((state) => { + if (!patch) { + const next = { ...state.syncingProjects }; + delete next[projectId]; + return { syncingProjects: next }; + } + const current = state.syncingProjects[projectId] || { + projectId, + total: 0, + completed: 0, + phase: "uploading", + }; + return { + syncingProjects: { + ...state.syncingProjects, + [projectId]: { ...current, ...patch }, + }, + }; + }), + incrementProjectCompleted: (projectId) => + set((state) => { + const current = state.syncingProjects[projectId]; + if (!current) return state; + return { + syncingProjects: { + ...state.syncingProjects, + [projectId]: { + ...current, + completed: Math.min(current.total, current.completed + 1), + }, + }, + }; + }), + clearAll: () => set({ syncingProjects: {} }), + isAnySyncing: () => { + const list = Object.values(get().syncingProjects); + return list.some((item) => item.phase === "uploading" || item.phase === "saving"); + }, +})); + +if (typeof window !== "undefined") { + window.addEventListener("beforeunload", (event) => { + if (useSyncProgressStore.getState().isAnySyncing()) { + event.preventDefault(); + event.returnValue = "画布正在同步至云端,请勿关闭页面。"; + return "画布正在同步至云端,请勿关闭页面。"; + } + }); +}