fix(canvas): 画布版本 - 合并 PR #528 防止旧内容覆盖并支持历史恢复
将画布 revision CAS 与历史快照接到 schema v23,保留当前顶栏积分弹窗和已有菜单入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import type { ReactNode } from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { PanelBottom, X, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { History, PanelBottom, X, ZoomIn, ZoomOut } from "lucide-react";
|
||||
|
||||
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
@@ -8,6 +9,9 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
|
||||
type CanvasFocusModeBarProps = {
|
||||
versionsOpen: boolean;
|
||||
onToggleVersions: () => void;
|
||||
syncStatus?: ReactNode;
|
||||
dockRevealed: boolean;
|
||||
zoomPercent: number;
|
||||
onToggleDock: () => void;
|
||||
@@ -17,7 +21,7 @@ type CanvasFocusModeBarProps = {
|
||||
onFit: () => void;
|
||||
};
|
||||
|
||||
export function CanvasFocusModeBar({ dockRevealed, zoomPercent, onToggleDock, onExit, onZoomIn, onZoomOut, onFit }: CanvasFocusModeBarProps) {
|
||||
export function CanvasFocusModeBar({ versionsOpen, onToggleVersions, syncStatus, dockRevealed, zoomPercent, onToggleDock, onExit, onZoomIn, onZoomOut, onFit }: CanvasFocusModeBarProps) {
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
@@ -42,6 +46,12 @@ export function CanvasFocusModeBar({ dockRevealed, zoomPercent, onToggleDock, on
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{syncStatus}
|
||||
<Tooltip title="版本记录与本地草稿">
|
||||
<button type="button" onClick={onToggleVersions} className="grid size-8 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text, background: versionsOpen ? theme.toolbar.itemHover : undefined }} aria-label="版本记录" aria-pressed={versionsOpen}>
|
||||
<History className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span className="mx-0.5 h-4 w-px" style={{ background: theme.toolbar.border }} />
|
||||
<Tooltip title={dockRevealed ? "收起工具" : "工具"}>
|
||||
<button
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import type { ViewportTransform } from "@/types/canvas";
|
||||
|
||||
type InfiniteCanvasProps = {
|
||||
interactive?: boolean;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
viewport: ViewportTransform;
|
||||
appearance?: CanvasAppearance;
|
||||
@@ -43,7 +44,7 @@ type PinchState = {
|
||||
initialScale: number;
|
||||
};
|
||||
|
||||
export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) {
|
||||
export function InfiniteCanvas({ interactive = true, containerRef, viewport, appearance, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) {
|
||||
const colorTheme = useActiveTheme();
|
||||
const resolvedAppearance = resolveCanvasAppearance(appearance, colorTheme);
|
||||
const panState = useRef({
|
||||
@@ -69,6 +70,27 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
|
||||
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (interactive) return;
|
||||
const container = containerRef.current;
|
||||
for (const id of [panState.current.pointerId, ...touchPointsRef.current.keys()]) {
|
||||
if (container?.hasPointerCapture(id)) container.releasePointerCapture(id);
|
||||
}
|
||||
panState.current.isPanning = false;
|
||||
pinchStateRef.current.active = false;
|
||||
touchPointsRef.current.clear();
|
||||
interactingRef.current = false;
|
||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
|
||||
frameRef.current = null;
|
||||
syncTimerRef.current = null;
|
||||
nextViewportRef.current = null;
|
||||
delete container?.dataset.canvasViewportInteracting;
|
||||
setIsPanning(false);
|
||||
setIsSpacePressed(false);
|
||||
document.body.style.cursor = "default";
|
||||
}, [interactive, containerRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (interactingRef.current) return;
|
||||
viewportRef.current = viewport;
|
||||
@@ -95,7 +117,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
|
||||
[containerRef],
|
||||
);
|
||||
|
||||
const syncViewport = useCallback(() => onViewportChange(viewportRef.current), [onViewportChange]);
|
||||
const syncViewport = useCallback(() => { if (interactive) onViewportChange(viewportRef.current); }, [interactive, onViewportChange]);
|
||||
|
||||
const scheduleViewportChange = useCallback(
|
||||
(next: ViewportTransform, commitAfterIdle = false) => {
|
||||
@@ -127,6 +149,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!interactive) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code !== "Space") return;
|
||||
if (event.target instanceof Element && event.target.closest("input,textarea,select,button,[contenteditable='true']")) return;
|
||||
@@ -154,7 +177,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
}, []);
|
||||
}, [interactive]);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(event: WheelEvent) => {
|
||||
@@ -208,6 +231,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
|
||||
);
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!interactive) return;
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
// AntD 浮层通过 Portal 渲染到节点 DOM 之外;若不统一排除,会被误判为画布空白并捕获指针。
|
||||
if (target?.closest(CANVAS_POINTER_IGNORE_SELECTOR)) return;
|
||||
@@ -297,6 +321,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!interactive) return;
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (event.pointerType === "touch" && touchPointsRef.current.has(event.pointerId)) {
|
||||
touchPointsRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
@@ -374,11 +399,11 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
|
||||
window.removeEventListener("pointerup", handlePointerEnd);
|
||||
window.removeEventListener("pointercancel", handlePointerEnd);
|
||||
};
|
||||
}, [containerRef, onCanvasDeselect, scheduleViewportChange, syncViewport]);
|
||||
}, [interactive, containerRef, onCanvasDeselect, scheduleViewportChange, syncViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (!container || !interactive) return;
|
||||
const updateRect = () => {
|
||||
containerRectRef.current = container.getBoundingClientRect();
|
||||
};
|
||||
@@ -392,7 +417,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
|
||||
window.removeEventListener("resize", updateRect);
|
||||
container.removeEventListener("wheel", handleWheel, { capture: true });
|
||||
};
|
||||
}, [containerRef, handleWheel]);
|
||||
}, [interactive, containerRef, handleWheel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import type { CanvasConnection, CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
type Change<T> = { before: T | null; after: T };
|
||||
type Change<T> = { before: T | null; after: T | null };
|
||||
export type AgentCanvasPatch = {
|
||||
canvasId: string;
|
||||
baseRevision?: number;
|
||||
revision?: number;
|
||||
updatedAt: string;
|
||||
nodes: Change<CanvasNodeData>[];
|
||||
connections: Change<CanvasConnection>[];
|
||||
@@ -44,8 +46,11 @@ function mergeItems<T extends { id: string }>(items: T[], changes: Change<T>[]):
|
||||
if (!changes.length) return items;
|
||||
const next = new Map(items.map((item) => [item.id, item]));
|
||||
for (const { before, after } of changes) {
|
||||
if (!after?.id || (before && before.id !== after.id)) throw new Error("无效的画布增量节点");
|
||||
next.set(after.id, mergeValue(next.get(after.id) ?? null, before, after) as T);
|
||||
const id = after?.id || before?.id;
|
||||
if (!id || (before && after && before.id !== after.id)) throw new Error("无效的画布增量节点");
|
||||
const merged = mergeValue(next.get(id) ?? null, before, after) as T | null;
|
||||
if (merged === null) next.delete(id);
|
||||
else next.set(id, merged);
|
||||
}
|
||||
const result = [...next.values()];
|
||||
return result.length === items.length && result.every((item, index) => item === items[index]) ? items : result;
|
||||
@@ -55,6 +60,7 @@ export function applyAgentCanvasPatch(project: CanvasProject, patch: AgentCanvas
|
||||
if (patch.canvasId !== project.id || !Array.isArray(patch.nodes) || !Array.isArray(patch.connections)) throw new Error("画布增量不属于当前画布或格式无效");
|
||||
const byId = new Map(project.nodes.map((node) => [node.id, node]));
|
||||
const nodeChanges = patch.nodes.map((change) => {
|
||||
if (!change.after) return change;
|
||||
const current = byId.get(change.after.id);
|
||||
const beforeTask = change.before?.metadata?.taskId;
|
||||
const afterTask = change.after.metadata?.taskId;
|
||||
@@ -81,8 +87,10 @@ export function mergeAgentCanvasEditor(previous: CanvasProject, incoming: Canvas
|
||||
const changes = <T extends { id: string }>(before: T[], after: T[]): Change<T>[] => {
|
||||
const byId = new Map(before.map((item) => [item.id, item]));
|
||||
const afterIds = new Set(after.map((item) => item.id));
|
||||
if (before.some((item) => !afterIds.has(item.id))) throw new Error("画布节点已删除,请先同步本地编辑");
|
||||
return after.filter((item) => !equal(item, byId.get(item.id))).map((item) => ({ before: byId.get(item.id) ?? null, after: item }));
|
||||
return [
|
||||
...after.filter((item) => !equal(item, byId.get(item.id))).map((item) => ({ before: byId.get(item.id) ?? null, after: item })),
|
||||
...before.filter((item) => !afterIds.has(item.id)).map((item) => ({ before: item, after: null })),
|
||||
];
|
||||
};
|
||||
return applyAgentCanvasPatch({ ...previous, nodes, connections }, {
|
||||
canvasId: previous.id,
|
||||
|
||||
26
web/src/lib/canvas/canvas-content.ts
Normal file
26
web/src/lib/canvas/canvas-content.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { canonicalize } from "json-canonicalize";
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
|
||||
// These fields describe local viewing / synchronization, not an edit to the document.
|
||||
export function canvasContentSnapshot(project: CanvasProject) {
|
||||
const { viewport: _viewport, updatedAt: _updatedAt, revision: _revision, remoteContentHash: _hash, ...content } = project;
|
||||
return { ...content, projectId: content.projectId || undefined };
|
||||
}
|
||||
|
||||
export function sameCanvasContent(left: CanvasProject | undefined, right: CanvasProject | undefined) {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return false;
|
||||
const a = canvasContentSnapshot(left) as Record<string, unknown>;
|
||||
const b = canvasContentSnapshot(right) as Record<string, unknown>;
|
||||
return [...new Set([...Object.keys(a), ...Object.keys(b)])].every((key) => a[key] === b[key] || canonicalize(a[key]) === canonicalize(b[key]));
|
||||
}
|
||||
|
||||
export async function canvasContentHash(project: CanvasProject) {
|
||||
const serialized = canonicalize(canvasContentSnapshot(project));
|
||||
// LAN HTTP deployments may lack Web Crypto. Keep an exact baseline there;
|
||||
// a lossy checksum could incorrectly discard an unsaved draft during login.
|
||||
if (!globalThis.crypto?.subtle) return `json:${serialized}`;
|
||||
const bytes = new TextEncoder().encode(serialized);
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { loadCanvasDrawing, loadCanvasDrawingPreview, loadCanvasDrawingRender } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import type { CanvasDrawingExport } from "@/types/canvas-export";
|
||||
|
||||
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "画布") {
|
||||
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "画布", options: { includeLocalDrawings?: boolean } = {}) {
|
||||
const zipFiles: { name: string; data: BlobPart }[] = [];
|
||||
const exportedProjects = await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
@@ -22,7 +22,7 @@ export async function exportCanvasProjects(projects: CanvasProject[], fileName =
|
||||
zipFiles.push({ name: path, data: blob });
|
||||
}),
|
||||
);
|
||||
const drawingDocuments = (await Promise.all(project.nodes.filter((node) => node.type === "drawing" && node.metadata?.drawingId).map(async (node): Promise<CanvasDrawingExport | null> => {
|
||||
const drawingDocuments = (await Promise.all(project.nodes.filter((node) => options.includeLocalDrawings !== false && node.type === "drawing" && node.metadata?.drawingId).map(async (node): Promise<CanvasDrawingExport | null> => {
|
||||
const drawingId = node.metadata?.drawingId;
|
||||
if (!drawingId) return null;
|
||||
const [saved, preview, render] = await Promise.all([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData } from "@/types/canvas";
|
||||
import { normalizeAssetCategory } from "@/lib/asset-category";
|
||||
import { sameCanvasContent } from "@/lib/canvas/canvas-content";
|
||||
|
||||
export type CanvasStorageTombstones = {
|
||||
projects: Record<string, number>;
|
||||
@@ -302,6 +303,12 @@ function mergeSessions(input: {
|
||||
}
|
||||
|
||||
function mergeProject(base: CanvasProject | undefined, local: CanvasProject, durable: CanvasProject, document: CanvasStorageDocument, baseRevision: number, nextRevision: number, conflicts: CanvasStorageConflict[]) {
|
||||
if (local.revision !== durable.revision) {
|
||||
// Never attach another tab's newer server revision to this tab's stale edits.
|
||||
// Preserve the whole edited branch; the server will reject its stale revision.
|
||||
if (local.revision === base?.revision && sameCanvasContent(base, local)) return { ...durable, viewport: local.viewport };
|
||||
return local;
|
||||
}
|
||||
const common = {
|
||||
projectId: local.id,
|
||||
tombstones: document.tombstones,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Clapperboard, CloudDownload, CloudUpload, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Save, Search, Share2, Trash2, Undo2, Upload } from "lucide-react";
|
||||
import { Clapperboard, CloudDownload, CloudUpload, CopyPlus, Focus, FolderKanban, Gauge, History, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Save, Search, Share2, Trash2, Undo2, Upload } from "lucide-react";
|
||||
import { Button, Dropdown, Tooltip } from "antd";
|
||||
|
||||
import { WorkspaceCreditGiftMark } from "@/components/layout/workspace-credit-gift-mark";
|
||||
@@ -16,6 +16,9 @@ import type { CanvasMediaPerformanceMode } from "@/types/canvas";
|
||||
import { CanvasShortcutsModal } from "./canvas-shortcuts-modal";
|
||||
|
||||
type CanvasTopBarProps = {
|
||||
syncStatus?: ReactNode;
|
||||
versionsOpen: boolean;
|
||||
onToggleVersions: () => void;
|
||||
title: string;
|
||||
titleDraft: string;
|
||||
isTitleEditing: boolean;
|
||||
@@ -45,6 +48,9 @@ type CanvasTopBarProps = {
|
||||
};
|
||||
|
||||
export function CanvasTopBar({
|
||||
syncStatus,
|
||||
versionsOpen,
|
||||
onToggleVersions,
|
||||
title,
|
||||
titleDraft,
|
||||
isTitleEditing,
|
||||
@@ -112,7 +118,7 @@ export function CanvasTopBar({
|
||||
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
|
||||
{ key: "save", icon: <Save className="size-4" />, label: <MenuLabel text="保存" shortcut="⌘ S" />, onClick: () => void onSave() },
|
||||
{ key: "force-save", icon: <CloudUpload className="size-4" />, label: "强制覆盖保存", onClick: onForceSave },
|
||||
{ key: "force-save", icon: <CloudUpload className="size-4" />, label: "修复素材关联并保存", onClick: onForceSave },
|
||||
{ type: "divider" },
|
||||
{ key: "import", icon: <Upload className="size-4" />, label: "导入素材", onClick: onImportImage },
|
||||
{ key: "search", icon: <Search className="size-4" />, label: <MenuLabel text="搜索节点" shortcut="⌘ F" />, onClick: onOpenSearch },
|
||||
@@ -187,9 +193,10 @@ export function CanvasTopBar({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{syncStatus}
|
||||
</div>
|
||||
|
||||
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5" style={dockStyle}>
|
||||
<div className="canvas-topbar-cluster canvas-topbar-tools-cluster pointer-events-auto flex items-center gap-1.5" style={dockStyle}>
|
||||
<CanvasTopBarTooltip label="搜索画布节点">
|
||||
<Button
|
||||
type="text"
|
||||
@@ -265,6 +272,19 @@ export function CanvasTopBar({
|
||||
</Button>
|
||||
</CanvasTopBarTooltip>
|
||||
) : null}
|
||||
<CanvasTopBarTooltip label="版本记录与本地草稿">
|
||||
<Button
|
||||
type="text"
|
||||
className="canvas-topbar-action canvas-topbar-version-button !h-10 !rounded-xl !px-2.5 !font-medium"
|
||||
style={{ color: theme.node.text, background: versionsOpen ? theme.toolbar.activeBg : undefined }}
|
||||
icon={<History className="size-4" />}
|
||||
aria-label="版本记录"
|
||||
aria-pressed={versionsOpen}
|
||||
onClick={onToggleVersions}
|
||||
>
|
||||
版本
|
||||
</Button>
|
||||
</CanvasTopBarTooltip>
|
||||
<CanvasTopBarTooltip label="分享画布">
|
||||
<Button type="text" className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Share2 className="size-4" />} onClick={onShare} aria-label="分享画布" />
|
||||
</CanvasTopBarTooltip>
|
||||
|
||||
137
web/src/pages/canvas/canvas-sync-status.tsx
Normal file
137
web/src/pages/canvas/canvas-sync-status.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { App, Button, Dropdown, Popover } from "antd";
|
||||
import { CloudCheck, CloudOff, LoaderCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
|
||||
import { readAllCanvasSyncDrafts, readCanvasSyncDrafts, type CanvasSyncDraft } from "@/services/canvas-sync-drafts";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
||||
|
||||
export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: { projectId: string; onLoadLatest: () => Promise<void>; onOpenVersions?: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const progress = useSyncProgressStore((state) => state.syncingProjects[projectId]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const phase = progress?.phase;
|
||||
const conflict = phase === "conflict";
|
||||
const failed = phase === "error" || conflict || !phase;
|
||||
const saving = phase === "pending" || phase === "saving" || phase === "uploading";
|
||||
const label = conflict ? "版本冲突 · 未同步" : !phase ? "尚未同步" : failed ? "云端未保存" : saving ? "正在保存" : "已保存";
|
||||
const run = async (operation: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "操作失败,请重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottom"
|
||||
open={statusOpen}
|
||||
onOpenChange={setStatusOpen}
|
||||
content={
|
||||
<div className="max-w-80 space-y-3" data-canvas-no-zoom>
|
||||
<p role="status" className="text-sm">
|
||||
{progress?.message || (phase === "done" ? "画布已同步到云端" : "尚未确认云端保存,请保留本地内容")}
|
||||
</p>
|
||||
{conflict ? <p className="text-xs text-muted-foreground">此画布的自动提交已暂停。加载最新版前会保留本地草稿,可下载后从画布列表导入为副本。</p> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="small" loading={busy} onClick={() => void run(onLoadLatest)}>
|
||||
加载云端最新版本
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
const project = useCanvasStore.getState().openProject(projectId);
|
||||
if (project) await exportCanvasProjects([project], `${project.title}-本地草稿`);
|
||||
})
|
||||
}
|
||||
>
|
||||
下载当前内容
|
||||
</Button>
|
||||
{onOpenVersions ? (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setStatusOpen(false);
|
||||
onOpenVersions();
|
||||
}}
|
||||
>
|
||||
版本记录{progress?.draftCount ? ` · ${progress.draftCount} 份草稿` : ""}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger={failed}
|
||||
aria-label={`画布保存状态:${label}`}
|
||||
icon={saving ? <LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" /> : failed ? <CloudOff className="size-3.5" /> : <CloudCheck className="size-3.5" />}
|
||||
>
|
||||
<span className="canvas-sync-status-label text-xs">{label}</span>
|
||||
</Button>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasSyncDraftMenu({ projectId }: { projectId?: string }) {
|
||||
const { message } = App.useApp();
|
||||
const [drafts, setDrafts] = useState<CanvasSyncDraft[]>([]);
|
||||
const [draftScope, setDraftScope] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) return;
|
||||
const scope = getActiveUserScope();
|
||||
setDraftScope(scope);
|
||||
setDrafts([]);
|
||||
setLoading(true);
|
||||
void (projectId ? readCanvasSyncDrafts(projectId, scope) : readAllCanvasSyncDrafts(scope))
|
||||
.then((items) => {
|
||||
if (getActiveUserScope() === scope) setDrafts(items.reverse());
|
||||
})
|
||||
.catch(() => message.error("读取本地草稿失败"))
|
||||
.finally(() => setLoading(false));
|
||||
}}
|
||||
menu={{
|
||||
items: drafts.length
|
||||
? drafts.map((draft) => ({
|
||||
key: draft.id,
|
||||
label: `${draft.project.title} · ${new Date(draft.savedAt).toLocaleString()}`,
|
||||
onClick: () => {
|
||||
if (getActiveUserScope() !== draftScope) {
|
||||
setDrafts([]);
|
||||
message.error("账号已切换,请重新打开本地草稿");
|
||||
return;
|
||||
}
|
||||
setExporting(true);
|
||||
void exportCanvasProjects([draft.project], `${draft.project.title}-本地草稿`, { includeLocalDrawings: false })
|
||||
.then(() => message.success("草稿已下载,可从画布列表导入为新画布"))
|
||||
.catch(() => message.error("草稿下载失败,请重试"))
|
||||
.finally(() => setExporting(false));
|
||||
},
|
||||
}))
|
||||
: [{ key: "empty", label: loading ? "正在读取草稿…" : "暂无本地草稿", disabled: true }],
|
||||
}}
|
||||
>
|
||||
<Button size={projectId ? "small" : "middle"} loading={exporting}>
|
||||
本地草稿
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
250
web/src/pages/canvas/canvas-version-history.css
Normal file
250
web/src/pages/canvas/canvas-version-history.css
Normal file
@@ -0,0 +1,250 @@
|
||||
.canvas-version-sidebar {
|
||||
width: 350px;
|
||||
flex: 0 0 350px;
|
||||
min-height: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.canvas-editor-shell {
|
||||
container-type: inline-size;
|
||||
}
|
||||
@container (max-width: 1050px) {
|
||||
.canvas-topbar-tools-cluster > :not(:has(.canvas-topbar-version-button)):not(.canvas-topbar-agent-button) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@container (max-width: 650px) {
|
||||
.canvas-topbar-project-picker {
|
||||
display: none !important;
|
||||
}
|
||||
.canvas-topbar-project-cluster {
|
||||
max-width: calc(100% - 180px);
|
||||
}
|
||||
.canvas-topbar {
|
||||
gap: 8px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
.canvas-sync-status-label {
|
||||
display: none;
|
||||
}
|
||||
.canvas-topbar-project-context {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.canvas-version-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--sidebar);
|
||||
color: var(--foreground);
|
||||
font-size: var(--fs-label);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.canvas-version-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 64px;
|
||||
padding: 0 18px;
|
||||
}
|
||||
.canvas-version-header h2 {
|
||||
flex: 1;
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
.canvas-version-tabs {
|
||||
display: flex;
|
||||
margin: 0 18px 14px;
|
||||
padding: 3px;
|
||||
gap: 3px;
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.canvas-version-tabs button {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 9px 4px;
|
||||
color: var(--muted-foreground);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.canvas-version-tabs button[aria-selected="true"] {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.canvas-version-count,
|
||||
.canvas-version-tag {
|
||||
font-size: var(--fs-tiny);
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.canvas-version-tag {
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--muted);
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.canvas-version-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 18px 20px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.canvas-version-current {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.canvas-version-current > span:nth-child(2) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.canvas-version-current strong,
|
||||
.canvas-version-current small {
|
||||
display: block;
|
||||
}
|
||||
.canvas-version-dot {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.canvas-version-current[aria-pressed="true"],
|
||||
.canvas-version-item[aria-pressed="true"] {
|
||||
background: var(--accent);
|
||||
border-color: var(--control-focus-ring);
|
||||
}
|
||||
.canvas-version-panel small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-tiny);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.canvas-version-hint {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-tiny);
|
||||
line-height: 1.8;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.canvas-version-error {
|
||||
color: var(--destructive);
|
||||
line-height: 1.8;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.canvas-version-empty {
|
||||
padding: 35px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.canvas-version-group {
|
||||
margin-top: 15px;
|
||||
}
|
||||
.canvas-version-group summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--muted-foreground);
|
||||
padding: 6px 0;
|
||||
}
|
||||
.canvas-version-group summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.canvas-version-group:not([open]) summary > svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.canvas-version-group summary > span:nth-child(2) {
|
||||
flex: 1;
|
||||
}
|
||||
.canvas-version-branches {
|
||||
border-left: 1px solid var(--border);
|
||||
margin: 6px 0 0 7px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
.canvas-version-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: block;
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.canvas-version-branches .canvas-version-item::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -21px;
|
||||
top: 19px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border: 1px solid var(--muted-foreground);
|
||||
border-radius: 50%;
|
||||
background: var(--sidebar);
|
||||
}
|
||||
.canvas-version-branches .canvas-version-item[aria-pressed="true"]::before {
|
||||
background: var(--foreground);
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
.canvas-version-item:hover,
|
||||
.canvas-version-current:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
.canvas-version-item-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.canvas-version-item-title > .canvas-version-tag {
|
||||
margin-left: auto;
|
||||
}
|
||||
.canvas-version-reason {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.canvas-version-draft-note {
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.8;
|
||||
margin: 0 0 15px;
|
||||
}
|
||||
.canvas-version-draft {
|
||||
border-color: var(--border);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.canvas-version-draft time {
|
||||
display: block;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-tiny);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.canvas-version-footer {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.canvas-version-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.canvas-version-panel button:focus-visible,
|
||||
.canvas-version-group summary:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.canvas-version-panel button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
429
web/src/pages/canvas/canvas-version-history.tsx
Normal file
429
web/src/pages/canvas/canvas-version-history.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
import { AppDrawer } from "@/components/ui/product/app-drawer";
|
||||
import { EmptyState } from "@/components/ui/product/empty-state";
|
||||
import { App, Button, Grid, Spin } from "antd";
|
||||
import { Check, ChevronDown, Cloud, Download, FileClock, History, RefreshCw, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getCanvasHistoryEntry, listCanvasHistory, type CanvasHistoryEntry } from "@/services/api/user-data";
|
||||
import { readCanvasSyncDrafts, type CanvasSyncDraft } from "@/services/canvas-sync-drafts";
|
||||
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
||||
import "./canvas-version-history.css";
|
||||
|
||||
export type CanvasVersionPreviewState = {
|
||||
key: string;
|
||||
label: string;
|
||||
date: string;
|
||||
kind: "cloud" | "draft";
|
||||
snapshot?: CanvasHistoryEntry;
|
||||
project?: CanvasProject;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function useCanvasVersionHistory(projectId: string, onRestore: (snapshotId: string, revision: number) => Promise<void>) {
|
||||
const { message, modal } = App.useApp();
|
||||
const desktop = Boolean(Grid.useBreakpoint().lg);
|
||||
const scope = getActiveUserScope();
|
||||
const contextRef = useRef({ projectId, scope });
|
||||
contextRef.current = { projectId, scope };
|
||||
const isCurrentContext = () => contextRef.current.projectId === projectId && contextRef.current.scope === scope && getActiveUserScope() === scope;
|
||||
const draftCount = useSyncProgressStore((state) => state.syncingProjects[projectId]?.draftCount);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [listOpen, setListOpen] = useState(true);
|
||||
const [tab, setTab] = useState<"cloud" | "draft">("cloud");
|
||||
const [entries, setEntries] = useState<CanvasHistoryEntry[]>([]);
|
||||
const [drafts, setDrafts] = useState<CanvasSyncDraft[]>([]);
|
||||
const [currentRevision, setCurrentRevision] = useState<number>();
|
||||
const [preview, setPreview] = useState<CanvasVersionPreviewState | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [draftLoading, setDraftLoading] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [draftError, setDraftError] = useState("");
|
||||
const [reload, setReload] = useState(0);
|
||||
const previewRequest = useRef<AbortController | null>(null);
|
||||
const returnToCurrent = useCallback(() => {
|
||||
previewRequest.current?.abort();
|
||||
setPreview(null);
|
||||
}, []);
|
||||
const close = useCallback(() => {
|
||||
if (!restoring) {
|
||||
setOpen(false);
|
||||
returnToCurrent();
|
||||
}
|
||||
}, [restoring, returnToCurrent]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
setTab("cloud");
|
||||
returnToCurrent();
|
||||
}, [projectId, scope, returnToCurrent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setEntries([]);
|
||||
setCurrentRevision(undefined);
|
||||
void listCanvasHistory(projectId, controller.signal)
|
||||
.then((result) => {
|
||||
if (controller.signal.aborted || !isCurrentContext()) return;
|
||||
setEntries(result.snapshots);
|
||||
// Keep the revision observed with this list as the restore precondition.
|
||||
setCurrentRevision(result.currentRevision);
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (!controller.signal.aborted && isCurrentContext()) setError(cause instanceof Error ? cause.message : "历史版本读取失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted && isCurrentContext()) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [open, projectId, reload, scope]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
setDraftLoading(true);
|
||||
setDraftError("");
|
||||
setDrafts([]);
|
||||
void readCanvasSyncDrafts(projectId, scope)
|
||||
.then((items) => {
|
||||
if (active && isCurrentContext()) setDrafts(items.sort((a, b) => b.savedAt.localeCompare(a.savedAt)));
|
||||
})
|
||||
.catch(() => {
|
||||
if (active && isCurrentContext()) setDraftError("本地草稿读取失败,请刷新重试");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active && isCurrentContext()) setDraftLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, projectId, reload, scope, draftCount]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
previewRequest.current?.abort();
|
||||
},
|
||||
[open, projectId, scope],
|
||||
);
|
||||
|
||||
const selectVersion = (entry: CanvasHistoryEntry) => {
|
||||
previewRequest.current?.abort();
|
||||
const controller = new AbortController();
|
||||
previewRequest.current = controller;
|
||||
const selection: CanvasVersionPreviewState = { key: entry.id, label: `版本 ${entry.revision}`, date: entry.contentUpdatedAt, kind: "cloud", snapshot: entry };
|
||||
setPreview(selection);
|
||||
setListOpen(false);
|
||||
void getCanvasHistoryEntry(projectId, entry.id, controller.signal)
|
||||
.then(({ project }) => {
|
||||
if (!controller.signal.aborted && previewRequest.current === controller && isCurrentContext()) setPreview({ ...selection, project });
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (!controller.signal.aborted && previewRequest.current === controller && isCurrentContext()) setPreview({ ...selection, error: cause instanceof Error ? cause.message : "预览读取失败,请重新选择版本" });
|
||||
});
|
||||
};
|
||||
|
||||
const restore = () => {
|
||||
const selected = preview?.snapshot;
|
||||
if (!selected || !preview.project || currentRevision === undefined || restoring) return;
|
||||
setConfirming(true);
|
||||
modal.confirm({
|
||||
title: `恢复版本 ${selected.revision} 的内容?`,
|
||||
content: "恢复前会备份当前云端内容,并保留本地草稿。恢复后会生成一个新版本,画布的项目归属保持当前设置。",
|
||||
okText: "恢复此版本",
|
||||
cancelText: "取消",
|
||||
afterClose: () => setConfirming(false),
|
||||
onOk: async () => {
|
||||
if (!isCurrentContext()) {
|
||||
message.error("画布或账号已切换,请重新打开版本记录");
|
||||
return;
|
||||
}
|
||||
setRestoring(true);
|
||||
try {
|
||||
await onRestore(selected.id, currentRevision);
|
||||
returnToCurrent();
|
||||
setReload((value) => value + 1);
|
||||
message.success("已恢复内容并保存为新版本");
|
||||
} catch (cause) {
|
||||
const detail = cause instanceof Error ? cause.message : "恢复失败,请重试";
|
||||
setError(detail);
|
||||
message.error(detail);
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const download = async () => {
|
||||
if (!preview?.project || !isCurrentContext()) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
// Drawing strokes are not versioned; never mix today's local strokes into an old snapshot.
|
||||
await exportCanvasProjects([preview.project], `${preview.project.title}-${preview.label}`, { includeLocalDrawings: false });
|
||||
message.success("已下载,可从画布列表导入为新画布");
|
||||
} catch (cause) {
|
||||
message.error(cause instanceof Error ? cause.message : "下载失败,请重试");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
open,
|
||||
listOpen,
|
||||
desktop,
|
||||
tab,
|
||||
entries,
|
||||
drafts,
|
||||
currentRevision,
|
||||
preview,
|
||||
loading,
|
||||
draftLoading,
|
||||
restoring,
|
||||
confirming,
|
||||
exporting,
|
||||
error,
|
||||
draftError,
|
||||
show: () => {
|
||||
setOpen(true);
|
||||
setListOpen(true);
|
||||
},
|
||||
toggle: () => {
|
||||
if (open && (desktop || listOpen)) close();
|
||||
else {
|
||||
setOpen(true);
|
||||
setListOpen(true);
|
||||
}
|
||||
},
|
||||
hideList: () => {
|
||||
if (!restoring) {
|
||||
setListOpen(false);
|
||||
if (!preview) setOpen(false);
|
||||
}
|
||||
},
|
||||
close,
|
||||
changeTab: (next: "cloud" | "draft") => {
|
||||
setTab(next);
|
||||
returnToCurrent();
|
||||
},
|
||||
refresh: () => {
|
||||
returnToCurrent();
|
||||
setReload((value) => value + 1);
|
||||
},
|
||||
selectVersion,
|
||||
selectDraft: (draft: CanvasSyncDraft) => {
|
||||
previewRequest.current?.abort();
|
||||
setPreview({ key: draft.id, label: "本地草稿", date: draft.savedAt, kind: "draft", project: draft.project });
|
||||
setListOpen(false);
|
||||
},
|
||||
returnToCurrent,
|
||||
restore,
|
||||
download,
|
||||
};
|
||||
}
|
||||
|
||||
export type CanvasVersionHistoryController = ReturnType<typeof useCanvasVersionHistory>;
|
||||
|
||||
export function CanvasVersionHistory({ history }: { history: CanvasVersionHistoryController }) {
|
||||
const { open, tab, entries, drafts, currentRevision, preview, loading, draftLoading, restoring, exporting, error, draftError } = history;
|
||||
if (!open) return null;
|
||||
const groups = new Map<string, CanvasHistoryEntry[]>();
|
||||
for (const entry of entries) {
|
||||
const day = new Date(entry.contentUpdatedAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" });
|
||||
groups.set(day, [...(groups.get(day) || []), entry]);
|
||||
}
|
||||
const content = (
|
||||
<div className="canvas-version-panel" data-canvas-no-zoom data-canvas-wheel-scroll>
|
||||
<header className="canvas-version-header">
|
||||
<History size={17} />
|
||||
<h2>版本记录</h2>
|
||||
<Button type="text" size="small" aria-label="刷新版本记录" icon={<RefreshCw size={15} />} disabled={restoring || loading} onClick={history.refresh} />
|
||||
<Button type="text" size="small" aria-label="关闭版本记录" icon={<X size={17} />} disabled={restoring} onClick={history.close} />
|
||||
</header>
|
||||
<div
|
||||
className="canvas-version-tabs"
|
||||
role="tablist"
|
||||
aria-label="版本来源"
|
||||
onKeyDown={(event) => {
|
||||
if (restoring || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? "cloud" : event.key === "End" ? "draft" : tab === "cloud" ? "draft" : "cloud";
|
||||
history.changeTab(next);
|
||||
event.currentTarget.querySelector<HTMLButtonElement>(`[data-tab="${next}"]`)?.focus();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id="canvas-cloud-tab"
|
||||
role="tab"
|
||||
data-tab="cloud"
|
||||
aria-selected={tab === "cloud"}
|
||||
aria-controls="canvas-version-list"
|
||||
tabIndex={tab === "cloud" ? 0 : -1}
|
||||
disabled={restoring}
|
||||
onClick={() => history.changeTab("cloud")}
|
||||
>
|
||||
<Cloud size={14} />
|
||||
云端历史
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="canvas-draft-tab"
|
||||
role="tab"
|
||||
data-tab="draft"
|
||||
aria-selected={tab === "draft"}
|
||||
aria-controls="canvas-version-list"
|
||||
tabIndex={tab === "draft" ? 0 : -1}
|
||||
disabled={restoring}
|
||||
onClick={() => history.changeTab("draft")}
|
||||
>
|
||||
<FileClock size={14} />
|
||||
本地草稿 <span className="canvas-version-count">{drafts.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="canvas-version-list" role="tabpanel" aria-labelledby={tab === "cloud" ? "canvas-cloud-tab" : "canvas-draft-tab"} className="canvas-version-list">
|
||||
{tab === "cloud" ? (
|
||||
<>
|
||||
<button type="button" className="canvas-version-current" aria-pressed={!preview} disabled={restoring} onClick={history.returnToCurrent}>
|
||||
<span className="canvas-version-dot">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>当前画布</strong>
|
||||
<small>{currentRevision === undefined ? "云端版本待确认" : `打开记录时云端为 v${currentRevision}`}</small>
|
||||
</span>
|
||||
{!preview ? <span className="canvas-version-tag">当前</span> : null}
|
||||
</button>
|
||||
<p className="canvas-version-hint">查看旧版不会修改当前画布</p>
|
||||
{error ? (
|
||||
<p role="alert" className="canvas-version-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<div className="canvas-version-empty">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : !entries.length && !error ? (
|
||||
<EmptyState size="compact" description="暂无历史版本,后续保存时会自动保留" />
|
||||
) : null}
|
||||
{[...groups].map(([day, items]) => (
|
||||
<details className="canvas-version-group" key={day} open>
|
||||
<summary>
|
||||
<ChevronDown size={14} />
|
||||
<span>{day}</span>
|
||||
<span className="canvas-version-count">{items.length}</span>
|
||||
</summary>
|
||||
<div className="canvas-version-branches">
|
||||
{items.map((entry) => (
|
||||
<button type="button" key={entry.id} className="canvas-version-item" disabled={restoring} aria-pressed={preview?.key === entry.id} onClick={() => history.selectVersion(entry)}>
|
||||
<span className="canvas-version-item-title">
|
||||
<time dateTime={entry.contentUpdatedAt}>{formatTime(entry.contentUpdatedAt)}</time>
|
||||
<span className="canvas-version-tag">v{entry.revision}</span>
|
||||
</span>
|
||||
<span className="canvas-version-reason">{entry.reason === "before_restore" ? "恢复前备份" : "自动保存"}</span>
|
||||
<small>
|
||||
{entry.nodeCount} 个节点 · {entry.connectionCount} 条连线
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="canvas-version-draft-note">发生冲突或恢复版本时保留的本机备份。下载后可从画布列表导入为新画布。</p>
|
||||
{draftError ? (
|
||||
<p role="alert" className="canvas-version-error">
|
||||
{draftError}
|
||||
</p>
|
||||
) : null}
|
||||
{draftLoading ? (
|
||||
<div className="canvas-version-empty">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : !drafts.length && !draftError ? (
|
||||
<EmptyState size="compact" description="暂无本地草稿" />
|
||||
) : null}
|
||||
{drafts.map((draft) => (
|
||||
<button type="button" key={draft.id} className="canvas-version-item canvas-version-draft" disabled={restoring} aria-pressed={preview?.key === draft.id} onClick={() => history.selectDraft(draft)}>
|
||||
<span className="canvas-version-item-title">
|
||||
<FileClock size={15} />
|
||||
<strong>本机备份</strong>
|
||||
{draft.project.revision !== undefined ? <span className="canvas-version-tag">基于 v{draft.project.revision}</span> : null}
|
||||
</span>
|
||||
<time dateTime={draft.savedAt}>{new Date(draft.savedAt).toLocaleString("zh-CN")}</time>
|
||||
<small>
|
||||
{draft.project.nodes.length} 个节点 · {draft.project.connections.length} 条连线
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<footer className="canvas-version-footer">
|
||||
{preview ? (
|
||||
<>
|
||||
<div className="canvas-version-item-title">
|
||||
<strong>{preview.label}</strong>
|
||||
<span className="canvas-version-tag">只读预览</span>
|
||||
</div>
|
||||
<small>{new Date(preview.date).toLocaleString("zh-CN")}</small>
|
||||
{preview.project ? (
|
||||
<small>
|
||||
{preview.project.nodes.length} 个节点 · {preview.project.connections.length} 条连线
|
||||
</small>
|
||||
) : null}
|
||||
{preview.project?.nodes.some((node) => node.type === "drawing") ? <p className="canvas-version-hint">绘图仅保留已上传的预览,不含本机历史笔画。</p> : null}
|
||||
<div className="canvas-version-actions">
|
||||
{preview.kind === "cloud" ? (
|
||||
<Button block type="primary" loading={restoring} disabled={!preview.project || currentRevision === undefined || loading} onClick={history.restore}>
|
||||
恢复此版本
|
||||
</Button>
|
||||
) : null}
|
||||
<Button block type={preview.kind === "draft" ? "primary" : "default"} icon={<Download size={14} />} loading={exporting} disabled={!preview.project || restoring} onClick={() => void history.download()}>
|
||||
{preview.kind === "draft" ? "下载草稿" : "下载此版本"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="canvas-version-hint">{tab === "cloud" ? "内容变化时最多每 5 分钟保留一份,保留最近 20 份。恢复前额外备份。" : "草稿仅存于此浏览器,建议及时下载保留。"}</p>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
return history.desktop ? (
|
||||
<aside className="canvas-version-sidebar" aria-label="版本记录">
|
||||
{content}
|
||||
</aside>
|
||||
) : (
|
||||
<AppDrawer
|
||||
flush
|
||||
open={history.listOpen}
|
||||
focusable={{ trap: !history.confirming }}
|
||||
placement="right"
|
||||
title={null}
|
||||
closable={false}
|
||||
onClose={history.hideList}
|
||||
maskClosable={!restoring}
|
||||
keyboard={!restoring}
|
||||
size="min(350px, 92vw)"
|
||||
aria-label="版本记录"
|
||||
>
|
||||
{content}
|
||||
</AppDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(date: string) {
|
||||
return new Date(date).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
}
|
||||
230
web/src/pages/canvas/canvas-version-preview.tsx
Normal file
230
web/src/pages/canvas/canvas-version-preview.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { EmptyState } from "@/components/ui/product/empty-state";
|
||||
import { Button, Spin } from "antd";
|
||||
import { ArrowLeft, Eye, History, Maximize, Minus, Plus } from "lucide-react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
|
||||
import { CanvasNode } from "@/components/canvas/canvas-node";
|
||||
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
|
||||
import { ConnectionPath } from "@/components/canvas/canvas-connections";
|
||||
import { CanvasNodeActionContext } from "@/components/canvas/canvas-node-action-context";
|
||||
import { CanvasNodeGraphContext } from "@/components/canvas/canvas-node-graph-context";
|
||||
import { isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame";
|
||||
import { getCanvasNodesBounds, viewportAtScale, viewportForBounds } from "@/lib/canvas/canvas-viewport";
|
||||
import { resolveCanvasAppearance } from "@/lib/canvas/canvas-appearance";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import type { CanvasNodeData, ViewportTransform } from "@/types/canvas";
|
||||
import type { CanvasVersionPreviewState } from "./canvas-version-history";
|
||||
|
||||
const noAction = () => undefined;
|
||||
const readOnlyActions = {};
|
||||
const previewNodeId = (id: string) => `version-preview:${id}`;
|
||||
|
||||
export function CanvasVersionPreview({ preview, onReturn, onShowVersions }: { preview: CanvasVersionPreviewState; onReturn: () => void; onShowVersions: () => void }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, k: 1 });
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [localMedia, setLocalMedia] = useState<Record<string, string>>({});
|
||||
const [mediaError, setMediaError] = useState("");
|
||||
const theme = useActiveTheme();
|
||||
const project = preview.project;
|
||||
useEffect(() => {
|
||||
const node = project?.nodes.find((item) => item.id === selectedId);
|
||||
const key = node?.metadata?.storageKey;
|
||||
if (!node || !key || resourceIdFromStorageKey(key) || !["video", "audio"].includes(node.type)) return;
|
||||
let active = true;
|
||||
setMediaError("");
|
||||
void resolveMediaUrl(key, node.metadata?.content)
|
||||
.then((url) => {
|
||||
if (active) setLocalMedia((current) => ({ ...current, [node.id]: url }));
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setMediaError("本机媒体读取失败,可下载草稿后检查素材");
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [project, selectedId]);
|
||||
const nodes = useMemo(
|
||||
() =>
|
||||
(project?.nodes || []).map((node) => {
|
||||
const resourceId = resourceIdFromStorageKey(node.metadata?.storageKey);
|
||||
return resourceId && ["image", "video", "audio"].includes(node.type)
|
||||
? { ...node, metadata: { ...node.metadata, content: resourceFileUrl(resourceId) } }
|
||||
: localMedia[node.id]
|
||||
? { ...node, metadata: { ...node.metadata, content: localMedia[node.id] } }
|
||||
: node;
|
||||
}),
|
||||
[project, localMedia],
|
||||
);
|
||||
const visibleNodes = useMemo(() => nodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, nodes)), [nodes]);
|
||||
const connections = useMemo(
|
||||
() =>
|
||||
(project?.connections || []).flatMap((connection) => {
|
||||
const resolved = resolveFrameConnection(connection, nodes);
|
||||
return resolved ? [{ connection, ...resolved }] : [];
|
||||
}),
|
||||
[project, nodes],
|
||||
);
|
||||
const graph = useMemo(() => {
|
||||
const byId = new Map(nodes.map((node) => [node.id, node]));
|
||||
return {
|
||||
getUpstreamNodes: (id: string) =>
|
||||
(project?.connections || [])
|
||||
.filter((connection) => previewNodeId(connection.toNodeId) === id)
|
||||
.flatMap((connection) => {
|
||||
const node = byId.get(connection.fromNodeId);
|
||||
return node ? [node] : [];
|
||||
}),
|
||||
};
|
||||
}, [nodes, project]);
|
||||
const fit = useCallback(() => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const sourceNodes = project?.nodes || [];
|
||||
const bounds = getCanvasNodesBounds(sourceNodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, sourceNodes)));
|
||||
if (rect && bounds) setViewport(viewportForBounds(bounds, rect, { padding: 72 }));
|
||||
}, [project]);
|
||||
useLayoutEffect(() => {
|
||||
fit();
|
||||
}, [fit]);
|
||||
const zoom = (scale: number) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (rect) setViewport((current) => viewportAtScale(current, rect, scale));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex min-h-0 flex-col" data-canvas-version-preview style={{ background: resolveCanvasAppearance(project?.appearance, theme).background }}>
|
||||
<header data-canvas-no-zoom className="grid shrink-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-b border-border bg-sidebar px-5 py-3 text-foreground sm:flex">
|
||||
<Eye size={16} className="text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<strong className="text-sm">{preview.label} · 只读预览</strong>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{new Date(preview.date).toLocaleString("zh-CN")}</p>
|
||||
</div>
|
||||
<Button type="text" className="lg:!hidden" icon={<History size={15} />} onClick={onShowVersions}>
|
||||
版本
|
||||
</Button>
|
||||
<Button className="col-span-3" icon={<ArrowLeft size={15} />} onClick={onReturn}>
|
||||
返回当前画布
|
||||
</Button>
|
||||
</header>
|
||||
{preview.error ? (
|
||||
<div role="alert" className="grid flex-1 place-content-center gap-4 p-6 text-center text-sm text-destructive">
|
||||
<p>{preview.error}</p>
|
||||
<Button onClick={onShowVersions}>重新选择版本</Button>
|
||||
</div>
|
||||
) : !project ? (
|
||||
<div className="grid flex-1 place-items-center">
|
||||
<Spin tip="正在读取版本内容">
|
||||
<div className="p-10" />
|
||||
</Spin>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{/* No editor callbacks, drawing cache key, or action context may cross this boundary. */}
|
||||
<CanvasNodeActionContext.Provider value={readOnlyActions}>
|
||||
<CanvasNodeGraphContext.Provider value={graph}>
|
||||
<InfiniteCanvas
|
||||
containerRef={containerRef}
|
||||
viewport={viewport}
|
||||
appearance={project.appearance}
|
||||
backgroundMode={project.backgroundMode}
|
||||
onViewportChange={setViewport}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onDrop={(event) => event.preventDefault()}
|
||||
onCanvasDeselect={() => setSelectedId(undefined)}
|
||||
>
|
||||
<svg className="pointer-events-none absolute overflow-visible" width={1} height={1}>
|
||||
{connections.map(({ connection, from, to }) => (
|
||||
<ConnectionPath key={connection.id} connection={{ ...connection, id: previewNodeId(connection.id) }} from={from} to={to} active={false} onSelect={noAction} />
|
||||
))}
|
||||
</svg>
|
||||
{visibleNodes.map((node) =>
|
||||
isFrameNode(node) ? (
|
||||
<CanvasFrameNode
|
||||
key={node.id}
|
||||
data={{ ...node, id: previewNodeId(node.id) }}
|
||||
childNodes={nodes.filter((child) => child.parentId === node.id)}
|
||||
scale={viewport.k}
|
||||
isSelected={false}
|
||||
isDropTarget={false}
|
||||
readOnly
|
||||
onMouseDown={noAction}
|
||||
onResize={noAction}
|
||||
onToggleCollapsed={noAction}
|
||||
onFolderStyleChange={noAction}
|
||||
onTitleChange={noAction}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
/>
|
||||
) : (
|
||||
<CanvasNode
|
||||
key={node.id}
|
||||
data={{ ...node, id: previewNodeId(node.id) }}
|
||||
scale={viewport.k}
|
||||
readOnly
|
||||
isSelected={selectedId === node.id}
|
||||
mediaActive={selectedId === node.id}
|
||||
isRelated={false}
|
||||
isFocusRelated={false}
|
||||
isConnectionTarget={false}
|
||||
showImageInfo={false}
|
||||
reduceMediaEffects
|
||||
renderNodeContent={renderReadOnlyContent}
|
||||
onMouseDown={() => setSelectedId(node.id)}
|
||||
onHoverStart={noAction}
|
||||
onHoverEnd={noAction}
|
||||
onConnectStart={noAction}
|
||||
onResize={noAction}
|
||||
onContentChange={noAction}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</InfiniteCanvas>
|
||||
</CanvasNodeGraphContext.Provider>
|
||||
</CanvasNodeActionContext.Provider>
|
||||
{!nodes.length ? (
|
||||
<div className="pointer-events-none absolute inset-0 grid place-items-center">
|
||||
<EmptyState description="此版本没有节点" />
|
||||
</div>
|
||||
) : null}
|
||||
{mediaError ? (
|
||||
<p role="alert" className="absolute left-5 top-3 rounded-md bg-sidebar p-2 text-xs text-destructive">
|
||||
{mediaError}
|
||||
</p>
|
||||
) : null}
|
||||
<div data-canvas-no-zoom className="absolute bottom-5 left-5 flex items-center gap-1 rounded-lg border border-border bg-sidebar p-1 text-foreground shadow-sm">
|
||||
<Button type="text" aria-label="缩小预览" icon={<Minus size={15} />} onClick={() => zoom(viewport.k / 1.2)} />
|
||||
<span className="w-12 text-center text-xs tabular-nums" aria-label="预览缩放比例">
|
||||
{Math.round(viewport.k * 100)}%
|
||||
</span>
|
||||
<Button type="text" aria-label="放大预览" icon={<Plus size={15} />} onClick={() => zoom(viewport.k * 1.2)} />
|
||||
<Button type="text" aria-label="适应预览内容" icon={<Maximize size={15} />} onClick={fit} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderReadOnlyContent(node: CanvasNodeData) {
|
||||
const rows = node.metadata?.storyboard?.rows;
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-lg bg-card text-card-foreground">
|
||||
<strong className="shrink-0 border-b border-border px-4 py-3 text-sm">{node.title}</strong>
|
||||
<div data-canvas-wheel-scroll className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words p-4 text-sm leading-6">
|
||||
{rows?.length
|
||||
? rows.map((row) => (
|
||||
<div key={row.id} className="mb-3 border-b border-border pb-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
镜头 {row.shotNumber} · {row.durationSeconds}s
|
||||
</p>
|
||||
<p>{row.plotDescription}</p>
|
||||
<p>{row.dialogue}</p>
|
||||
</div>
|
||||
))
|
||||
: node.metadata?.content || node.metadata?.composerContent || node.metadata?.prompt || "此节点没有文本内容"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { resourceFileUrl, resourceStorageKey, uploadResourceFile } from "@/servi
|
||||
import { primeResourceBlobCache } from "@/services/resource-blob-cache";
|
||||
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
||||
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
|
||||
import { CanvasSyncDraftMenu } from "./canvas-sync-status";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
|
||||
const CanvasDeleteProjectsDialog = lazy(() => import("@/components/canvas/canvas-delete-projects-dialog").then((module) => ({ default: module.CanvasDeleteProjectsDialog })));
|
||||
@@ -358,15 +359,21 @@ export default function CanvasPage() {
|
||||
await flushCanvasStorePersistence();
|
||||
if (remoteSyncEnabled) {
|
||||
try {
|
||||
await saveRemoteUserDataNow();
|
||||
await saveRemoteUserDataNow(importedProjectId);
|
||||
} catch (syncError) {
|
||||
remoteSyncWarning ||= syncError;
|
||||
scheduleRemoteUserDataSync();
|
||||
console.warn("导入画布云端同步失败,等待自动重试", syncError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
useSyncProgressStore.getState().setProjectProgress(importedProjectId, {
|
||||
phase: "error",
|
||||
message: error instanceof Error ? error.message : "画布导入未完成",
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
useSyncProgressStore.getState().setProjectProgress(importedProjectId, null);
|
||||
if (!remoteSyncEnabled) useSyncProgressStore.getState().setProjectProgress(importedProjectId, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,6 +416,7 @@ export default function CanvasPage() {
|
||||
meta={<span className="app-projects-header-meta">{totalProjects} 个</span>}
|
||||
actions={
|
||||
<div className="collection-header-actions">
|
||||
<CanvasSyncDraftMenu />
|
||||
<Button type="primary" disabled={!hydrated} icon={<Plus />} onClick={createAndEnter}>
|
||||
新建画布
|
||||
</Button>
|
||||
|
||||
@@ -100,6 +100,9 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard
|
||||
import { CanvasOperationChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
|
||||
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
|
||||
import { cancelGenerationTask } from "@/services/api/task-center";
|
||||
import { CanvasSyncStatus } from "./canvas-sync-status";
|
||||
import { CanvasVersionHistory, useCanvasVersionHistory } from "./canvas-version-history";
|
||||
import { CanvasVersionPreview } from "./canvas-version-preview";
|
||||
import { CanvasTopBar } from "./canvas-project-top-bar";
|
||||
import { LibTVImportDialog } from "./components/libtv-import-dialog";
|
||||
import { TapNowImportDialog } from "./components/tapnow-import-dialog";
|
||||
@@ -333,17 +336,9 @@ function InfiniteCanvasPage() {
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const [shortcutRequestNonce, setShortcutRequestNonce] = useState(0);
|
||||
const [cinematicAgentEntry, setCinematicAgentEntry] = useState(false);
|
||||
const { assistantOpen, closeAgent, openAgent } = useCanvasAssistantVisibility();
|
||||
const { assistantOpen, closeAgent, openAgent: openAssistant } = useCanvasAssistantVisibility();
|
||||
const agentMentionReferences = useMemo(() => buildCanvasAgentMentionReferences(nodes), [nodes]);
|
||||
|
||||
const sendSelectionToAgent = useCallback((nodeId?: string) => {
|
||||
const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current);
|
||||
const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference));
|
||||
if (!references.length) return;
|
||||
setAgentPrefillPrompt(`${references.map(canvasResourceMentionToken).join(" ")} `);
|
||||
openAgent();
|
||||
setContextMenu(null);
|
||||
}, [agentMentionReferences, openAgent]);
|
||||
const { tasks: activeTasks } = useCanvasActiveTasks(projectId, projectLoaded);
|
||||
const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode();
|
||||
const [focusDockRevealed, setFocusDockRevealed] = useState(false);
|
||||
@@ -427,7 +422,7 @@ function InfiniteCanvasPage() {
|
||||
[cleanupAssetImages, getHistoryCleanupContext],
|
||||
);
|
||||
|
||||
const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({
|
||||
const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, reloadLatestCanvasProject, restoreCanvasProjectVersion, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({
|
||||
projectId,
|
||||
projectLoaded,
|
||||
nodes,
|
||||
@@ -440,6 +435,8 @@ function InfiniteCanvasPage() {
|
||||
viewport,
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
chatSessionsRef,
|
||||
activeChatIdRef,
|
||||
viewportRef,
|
||||
historyPausedRef,
|
||||
setNodes,
|
||||
@@ -456,13 +453,24 @@ function InfiniteCanvasPage() {
|
||||
cleanupCanvasFiles,
|
||||
});
|
||||
|
||||
// 强制覆盖会改写云端版本并重绑媒体素材关联,必须让用户显式确认 destructive 语义。
|
||||
const versions = useCanvasVersionHistory(projectId, restoreCanvasProjectVersion);
|
||||
const openVersions = () => { closeAgent(); setVersionCompareRootId(null); versions.show(); };
|
||||
const openAgent = useCallback(() => { versions.close(); openAssistant(); }, [versions.close, openAssistant]);
|
||||
|
||||
const sendSelectionToAgent = useCallback((nodeId?: string) => {
|
||||
const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current);
|
||||
const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference));
|
||||
if (!references.length) return;
|
||||
setAgentPrefillPrompt(`${references.map(canvasResourceMentionToken).join(" ")} `);
|
||||
openAgent();
|
||||
setContextMenu(null);
|
||||
}, [agentMentionReferences, openAgent]);
|
||||
// 修复素材关联仍遵守当前画布版本,不能替用户确认覆盖云端的新内容。
|
||||
const confirmForceSaveCanvas = useCallback(() => {
|
||||
modal.confirm({
|
||||
title: "用本地内容强制覆盖云端?",
|
||||
content: "将把当前本地画布保存并覆盖云端版本,同时自动修复画布媒体与素材库的绑定(缺少素材记录时会按节点新建)。云端尚未同步到本地的改动会被覆盖。",
|
||||
okText: "强制覆盖保存",
|
||||
okButtonProps: { danger: true },
|
||||
title: "修复素材关联并保存?",
|
||||
content: "核对画布媒体与素材库的关联,补齐缺失素材后保存。若云端已有新版本,会保留本地草稿并提示加载最新版。",
|
||||
okText: "修复并保存",
|
||||
cancelText: "取消",
|
||||
onOk: () => forceSaveCanvasProject(),
|
||||
});
|
||||
@@ -478,7 +486,7 @@ function InfiniteCanvasPage() {
|
||||
connectionsRef.current = nextConnections;
|
||||
setNodes(nextNodes);
|
||||
setConnections(nextConnections);
|
||||
const saved = await saveCanvasProject();
|
||||
const saved = await saveCanvasProject({ requireRemote: false });
|
||||
if (!saved) {
|
||||
nodesRef.current = previousNodes;
|
||||
connectionsRef.current = previousConnections;
|
||||
@@ -499,7 +507,7 @@ function InfiniteCanvasPage() {
|
||||
connectionsRef.current = nextConnections;
|
||||
setNodes(nextNodes);
|
||||
setConnections(nextConnections);
|
||||
const saved = await saveCanvasProject();
|
||||
const saved = await saveCanvasProject({ requireRemote: false });
|
||||
if (!saved) {
|
||||
nodesRef.current = previousNodes;
|
||||
connectionsRef.current = previousConnections;
|
||||
@@ -1628,6 +1636,7 @@ function InfiniteCanvasPage() {
|
||||
}, [clearCanvasFiles, deselectCanvas, message, nodesRef, projectId, setEmotionNodeId]);
|
||||
|
||||
useCanvasKeyboard({
|
||||
enabled: projectLoaded && !versions.preview,
|
||||
nodesRef,
|
||||
selectedNodeIdsRef,
|
||||
selectedConnectionId,
|
||||
@@ -2341,13 +2350,17 @@ function InfiniteCanvasPage() {
|
||||
跳转到画布主内容
|
||||
</a>
|
||||
<main id="canvas-main" tabIndex={-1} className="flex h-full min-h-0 overflow-hidden outline-none" style={{ background: resolvedCanvasAppearance.background, color: theme.node.text }}>
|
||||
{!focusMode && shortDramaEnabled && currentProject?.projectId ? (
|
||||
{!focusMode && !versions.preview && shortDramaEnabled && currentProject?.projectId ? (
|
||||
<CanvasProjectSidebar projectId={currentProject.projectId} detail={linkedProjectQuery.data} onAddChapter={handleProjectChapterInsert} onLocateStyle={locateProjectStyleNode} onOpenAssets={() => openProjectAssets()} />
|
||||
) : null}
|
||||
<CanvasOverlayLayerProvider>
|
||||
<section className="relative min-w-0 flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<div className="canvas-editor-shell relative flex min-w-0 flex-1">
|
||||
<section data-canvas-editor inert={Boolean(versions.preview)} style={{ visibility: versions.preview ? "hidden" : undefined, opacity: versions.preview ? 0 : undefined }} className="relative min-w-0 flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{!focusMode ? (
|
||||
<CanvasTopBar
|
||||
syncStatus={<CanvasSyncStatus projectId={projectId} onLoadLatest={reloadLatestCanvasProject} onOpenVersions={openVersions} />}
|
||||
versionsOpen={versions.open}
|
||||
onToggleVersions={() => { closeAgent(); setVersionCompareRootId(null); versions.toggle(); }}
|
||||
title={currentProject?.title || "未命名画布"}
|
||||
titleDraft={titleDraft}
|
||||
isTitleEditing={titleEditing}
|
||||
@@ -2418,6 +2431,7 @@ function InfiniteCanvasPage() {
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1">
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden">
|
||||
<InfiniteCanvas
|
||||
interactive={!versions.preview}
|
||||
containerRef={containerRef}
|
||||
viewport={viewport}
|
||||
appearance={canvasAppearance}
|
||||
@@ -2537,6 +2551,9 @@ function InfiniteCanvasPage() {
|
||||
|
||||
{focusMode ? (
|
||||
<CanvasFocusModeBar
|
||||
syncStatus={<CanvasSyncStatus projectId={projectId} onLoadLatest={reloadLatestCanvasProject} onOpenVersions={openVersions} />}
|
||||
versionsOpen={versions.open}
|
||||
onToggleVersions={() => { closeAgent(); versions.toggle(); }}
|
||||
dockRevealed={focusDockRevealed}
|
||||
zoomPercent={viewport.k}
|
||||
onToggleDock={() => setFocusDockRevealed((value) => !value)}
|
||||
@@ -2593,10 +2610,12 @@ function InfiniteCanvasPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={versions.open ? "hidden" : "contents"}>
|
||||
<CanvasCloudAgentPanel canvasId={projectId} domainProjectId={currentProject?.projectId} nodeCount={nodes.length} references={agentMentionReferences} prefillPrompt={agentPrefillPrompt} open={assistantOpen} onOpen={openAgent} onCollapse={closeAgent} onFocusNode={(nodeId) => {
|
||||
if (!nodesRef.current.some((node) => node.id === nodeId)) { message.info("该节点已删除或尚未同步到画布"); return; }
|
||||
focusCanvasNode(nodeId);
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{angleNode?.metadata?.content ? (
|
||||
@@ -3104,7 +3123,10 @@ function InfiniteCanvasPage() {
|
||||
onInsertFolder={projectAssetScope === "canvas" ? handleProjectFolderInsert : undefined}
|
||||
/>
|
||||
</section>
|
||||
{versions.preview ? <CanvasVersionPreview key={versions.preview.key} preview={versions.preview} onReturn={versions.returnToCurrent} onShowVersions={versions.show} /> : null}
|
||||
</div>
|
||||
</CanvasOverlayLayerProvider>
|
||||
<CanvasVersionHistory history={versions} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, type Dispatch, type SetStateAction } from "react";
|
||||
import type { CanvasNodeData, ContextMenuState } from "@/types/canvas";
|
||||
|
||||
type UseCanvasKeyboardOptions = {
|
||||
enabled?: boolean;
|
||||
nodesRef: { current: CanvasNodeData[] };
|
||||
selectedNodeIdsRef: { current: Set<string> };
|
||||
selectedConnectionId: string | null;
|
||||
@@ -49,6 +50,7 @@ export function hasCanvasTextSelection(selection: TextSelectionLike | null | und
|
||||
}
|
||||
|
||||
export function useCanvasKeyboard({
|
||||
enabled = true,
|
||||
nodesRef,
|
||||
selectedNodeIdsRef,
|
||||
selectedConnectionId,
|
||||
@@ -84,6 +86,7 @@ export function useCanvasKeyboard({
|
||||
beginBatchConnection,
|
||||
}: UseCanvasKeyboardOptions) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (target?.closest(".canvas-node-toolbar, .canvas-node-toolbar-menu")) return;
|
||||
@@ -219,5 +222,5 @@ export function useCanvasKeyboard({
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("paste", handlePaste, true);
|
||||
};
|
||||
}, [beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, onOpenSearch, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]);
|
||||
}, [enabled, beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, onOpenSearch, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mergeAgentCanvasEditor } from "@/lib/canvas/agent-canvas-patch";
|
||||
import { startTransition, useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import { App } from "antd";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
@@ -9,9 +9,11 @@ import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
|
||||
import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, forceOverwriteRemoteCanvasSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
|
||||
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, forceOverwriteRemoteCanvasSync, loadCanvasProjectForEditing, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
|
||||
import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { useCanvasThemeStore } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
||||
import { readCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
|
||||
import type { CanvasHistorySnapshot } from "./use-canvas-history";
|
||||
@@ -29,6 +31,8 @@ type UseCanvasProjectLifecycleOptions = {
|
||||
viewport: ViewportTransform;
|
||||
nodesRef: MutableRefObject<CanvasNodeData[]>;
|
||||
connectionsRef: MutableRefObject<CanvasConnection[]>;
|
||||
chatSessionsRef: MutableRefObject<CanvasAssistantSession[]>;
|
||||
activeChatIdRef: MutableRefObject<string | null>;
|
||||
viewportRef: MutableRefObject<ViewportTransform>;
|
||||
historyPausedRef: MutableRefObject<boolean>;
|
||||
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
|
||||
@@ -58,6 +62,8 @@ export function useCanvasProjectLifecycle({
|
||||
viewport,
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
chatSessionsRef,
|
||||
activeChatIdRef,
|
||||
viewportRef,
|
||||
historyPausedRef,
|
||||
setNodes,
|
||||
@@ -87,11 +93,20 @@ export function useCanvasProjectLifecycle({
|
||||
const [agentCreatedNodes, setAgentCreatedNodes] = useState<{ projectId: string; nodes: CanvasNodeData[] } | null>(null);
|
||||
const viewportSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const observedContentRef = useRef<CanvasHistorySnapshot | null>(null);
|
||||
const loadLatestRef = useRef(false);
|
||||
const historyRestoreRef = useRef<{ snapshotId: string; revision: number; resolve: () => void; reject: (error: unknown) => void } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !sessionHydrated) return;
|
||||
let cancelled = false;
|
||||
setProjectLoaded(false);
|
||||
setLoadError("");
|
||||
const latest = loadLatestRef.current;
|
||||
loadLatestRef.current = false;
|
||||
const historyRestore = historyRestoreRef.current;
|
||||
historyRestoreRef.current = null;
|
||||
observedContentRef.current = null;
|
||||
const applyRestoredProject = (targetProject: CanvasProject) => {
|
||||
if (cancelled) return;
|
||||
const fallbackTheme = useCanvasThemeStore.getState().theme;
|
||||
@@ -111,6 +126,9 @@ export function useCanvasProjectLifecycle({
|
||||
backgroundMode: targetProject.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE,
|
||||
showImageInfo: targetProject.showImageInfo || false,
|
||||
};
|
||||
observedContentRef.current = snapshot;
|
||||
chatSessionsRef.current = snapshot.chatSessions;
|
||||
activeChatIdRef.current = snapshot.activeChatId;
|
||||
nodesRef.current = snapshot.nodes;
|
||||
connectionsRef.current = snapshot.connections;
|
||||
viewportRef.current = targetProject.viewport;
|
||||
@@ -129,33 +147,40 @@ export function useCanvasProjectLifecycle({
|
||||
|
||||
const load = async () => {
|
||||
const cachedProject = useCanvasStore.getState().projects.find((p) => p.id === projectId);
|
||||
if (cachedProject && cachedProject.nodes?.length) {
|
||||
if (!latest && !historyRestore && cachedProject && cachedProject.nodes?.length) {
|
||||
// 本地已有该画布的持久化缓存:先以本地数据秒开渲染,彻底消除白屏与等待
|
||||
applyRestoredProject(cachedProject);
|
||||
}
|
||||
const loadedProject = await loadCanvasProjectForEditing(projectId);
|
||||
const loadedProject = await loadCanvasProjectForEditing(projectId, { latest, historyRestore: historyRestore || undefined, onLoad: applyRestoredProject });
|
||||
historyRestore?.resolve();
|
||||
if (cancelled) return;
|
||||
if (!loadedProject) {
|
||||
if (!cachedProject) navigate("/canvas", { replace: true });
|
||||
return;
|
||||
}
|
||||
const project = useCanvasStore.getState().projects.find((p) => p.id === projectId) || loadedProject;
|
||||
applyRestoredProject(project);
|
||||
|
||||
// 画布媒体由节点自己的视口观察器按需加载;打开时遍历并解析全部节点会让大画布形成 N+1 资源读取。
|
||||
void hydrateAssistantImages(project.chatSessions || [])
|
||||
.then((hydratedSessions) => {
|
||||
if (!cancelled) setChatSessions((current) => mergeHydratedSessions(current, hydratedSessions));
|
||||
if (!cancelled) setChatSessions((current) => {
|
||||
const merged = mergeHydratedSessions(current, hydratedSessions);
|
||||
if (observedContentRef.current?.chatSessions === current) observedContentRef.current = { ...observedContentRef.current, chatSessions: merged };
|
||||
return merged;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) message.warning("部分助手会话素材恢复失败,已使用项目记录继续打开");
|
||||
});
|
||||
};
|
||||
void load().catch((error) => {
|
||||
historyRestore?.reject(error);
|
||||
if (!cancelled && useSyncProgressStore.getState().syncingProjects[projectId]?.phase !== "conflict") useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "读取云端版本失败" });
|
||||
if (!cancelled) setLoadError(error instanceof Error ? error.message : "读取画布失败,请重试");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
historyRestore?.reject(new Error("画布页面已关闭,请重新打开查看恢复结果"));
|
||||
};
|
||||
}, [hydrated, sessionHydrated, loadAttempt, message, navigate, openProject, projectId, resetHistory, setActiveChatId, setBackgroundMode, setCanvasAppearance, setChatSessions, setConnections, setNodes, setShowImageInfo, setViewport]);
|
||||
|
||||
@@ -179,6 +204,13 @@ export function useCanvasProjectLifecycle({
|
||||
// Merge only server-changed fields so dragging/editing other nodes can
|
||||
// continue while Agent media tasks complete. Same-field conflicts fail.
|
||||
const merged = previous ? mergeAgentCanvasEditor(previous, project, nodesRef.current, connectionsRef.current) : project;
|
||||
if (observedContentRef.current) {
|
||||
const observed = observedContentRef.current;
|
||||
// Advance only the observed server fields; edits in live refs still
|
||||
// differ from this baseline and must be persisted by the effect below.
|
||||
const baseline = previous ? mergeAgentCanvasEditor(previous, project, observed.nodes, observed.connections) : project;
|
||||
observedContentRef.current = { ...observed, nodes: baseline.nodes, connections: baseline.connections };
|
||||
}
|
||||
nodesRef.current = merged.nodes;
|
||||
connectionsRef.current = merged.connections;
|
||||
setNodes(merged.nodes);
|
||||
@@ -190,6 +222,9 @@ export function useCanvasProjectLifecycle({
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectLoaded || historyPausedRef.current) return;
|
||||
const snapshot = { nodes, connections, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo };
|
||||
if (!observedContentRef.current || JSON.stringify(observedContentRef.current) === JSON.stringify(snapshot)) return;
|
||||
observedContentRef.current = snapshot;
|
||||
const patch = { nodes, connections, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo };
|
||||
const stored = useCanvasStore.getState().projects.find((project) => project.id === projectId);
|
||||
// 远端结果投影到编辑器不是一次本地编辑,避免改写时间戳并触发反向保存。
|
||||
@@ -223,8 +258,11 @@ export function useCanvasProjectLifecycle({
|
||||
}, [message, navigate]);
|
||||
|
||||
const deleteCurrentProject = useCallback(async () => {
|
||||
const drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []);
|
||||
let drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []);
|
||||
try {
|
||||
const drafts = await readCanvasSyncDrafts(projectId);
|
||||
const preserved = new Set(drafts.flatMap((draft) => draft.project.nodes.flatMap((node) => node.metadata?.drawingId ? [node.metadata.drawingId] : [])));
|
||||
drawingIds = drawingIds.filter((id) => !preserved.has(id));
|
||||
await deleteCanvasProjectsWithRemoteSync([projectId]);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? `删除画布失败:${error.message}` : "删除画布失败,请稍后重试");
|
||||
@@ -242,8 +280,9 @@ export function useCanvasProjectLifecycle({
|
||||
renameProject(projectId, title);
|
||||
}, [projectId, renameProject]);
|
||||
|
||||
const persistCanvasSnapshot = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const persistLocalEdits = useCallback(async () => {
|
||||
const snapshot = { nodes: nodesRef.current, connections: connectionsRef.current, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo };
|
||||
if (observedContentRef.current && JSON.stringify(observedContentRef.current) !== JSON.stringify(snapshot)) {
|
||||
updateProject(projectId, {
|
||||
nodes: nodesRef.current,
|
||||
connections: connectionsRef.current,
|
||||
@@ -253,38 +292,58 @@ export function useCanvasProjectLifecycle({
|
||||
backgroundMode,
|
||||
showImageInfo,
|
||||
viewport: viewportRef.current,
|
||||
directorScenes: currentProject?.directorScenes || [],
|
||||
});
|
||||
await flushCanvasStorePersistence();
|
||||
return true;
|
||||
observedContentRef.current = snapshot;
|
||||
}
|
||||
updateProject(projectId, { viewport: viewportRef.current });
|
||||
await flushCanvasStorePersistence();
|
||||
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
|
||||
|
||||
const reloadLatestCanvasProject = useCallback(async () => {
|
||||
await persistLocalEdits();
|
||||
loadLatestRef.current = true;
|
||||
setLoadAttempt((value) => value + 1);
|
||||
}, [persistLocalEdits]);
|
||||
|
||||
const restoreCanvasProjectVersion = useCallback(async (snapshotId: string, revision: number) => {
|
||||
await persistLocalEdits();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
historyRestoreRef.current?.reject(new Error("已有新的恢复请求"));
|
||||
historyRestoreRef.current = { snapshotId, revision, resolve, reject };
|
||||
setLoadAttempt((value) => value + 1);
|
||||
});
|
||||
}, [persistLocalEdits]);
|
||||
|
||||
const saveCanvasProject = useCallback(async (options: { requireRemote?: boolean } = {}): Promise<boolean> => {
|
||||
try {
|
||||
await persistLocalEdits();
|
||||
} catch {
|
||||
message.error("画布保存失败,请稍后重试");
|
||||
return false;
|
||||
}
|
||||
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
|
||||
|
||||
const saveCanvasProject = useCallback(async (): Promise<boolean> => {
|
||||
if (!(await persistCanvasSnapshot())) return false;
|
||||
try {
|
||||
await saveRemoteUserDataNow();
|
||||
message.success("画布布局和位置已保存");
|
||||
await saveRemoteUserDataNow(projectId);
|
||||
message.success("画布已保存到云端");
|
||||
} catch (error) {
|
||||
// 本地 IndexedDB 已写入;云端失败只排队重试,不能把导入方的画布状态回滚。
|
||||
message.warning(localSavedRemotePendingMessage("本地画布布局已保存", error));
|
||||
const detail = error instanceof Error ? error.message : "未知错误";
|
||||
message.warning(`本地画布布局已保存,云端同步失败:${detail}`);
|
||||
// Imports can retain their durable local result; sharing requires cloud success.
|
||||
return options.requireRemote === false;
|
||||
}
|
||||
return true;
|
||||
}, [message, persistCanvasSnapshot]);
|
||||
}, [message, persistLocalEdits, projectId]);
|
||||
|
||||
const forceSaveCanvasProject = useCallback(async (): Promise<boolean> => {
|
||||
if (!(await persistCanvasSnapshot())) return false;
|
||||
try { await persistLocalEdits(); } catch { message.error("本地保存失败,请重试"); return false; }
|
||||
try {
|
||||
const result = await forceOverwriteRemoteCanvasSync();
|
||||
message.success(result.reboundNodes > 0 ? `已用本地内容覆盖云端,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "已用本地内容覆盖云端画布");
|
||||
message.success(result.reboundNodes > 0 ? `已保存,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "素材关联已核对,画布已保存");
|
||||
} catch (error) {
|
||||
message.error(`强制覆盖保存失败:${error instanceof Error ? error.message : "未知错误"}`);
|
||||
message.error(`修复并保存失败:${error instanceof Error ? error.message : "未知错误"}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [message, persistCanvasSnapshot]);
|
||||
}, [message, persistLocalEdits]);
|
||||
|
||||
const clearCanvasFiles = useCallback(() => {
|
||||
cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] });
|
||||
@@ -300,6 +359,8 @@ export function useCanvasProjectLifecycle({
|
||||
currentProject,
|
||||
deleteCurrentProject,
|
||||
renameCurrentProject,
|
||||
reloadLatestCanvasProject,
|
||||
restoreCanvasProjectVersion,
|
||||
saveCanvasProject,
|
||||
forceSaveCanvasProject,
|
||||
updateProject,
|
||||
|
||||
@@ -53,7 +53,7 @@ export function createAgentCanvasSync(options: Options) {
|
||||
if (patch && patch.canvasId === options.canvasId) {
|
||||
supportsPatches = true;
|
||||
patches.push(patch);
|
||||
} else if (!supportsPatches && (event.type === "canvas_updated" || event.type === "generation_task_created" || event.type === "tool_failed" || event.type === "run_failed" || (event.type === "tool_completed" && ["canvas_apply_ops", "generate_media"].includes(String(event.payload.toolName))))) {
|
||||
} else if (event.type === "canvas_updated" || event.type === "canvas_undone" || (!supportsPatches && (event.type === "generation_task_created" || event.type === "tool_failed" || event.type === "run_failed" || (event.type === "tool_completed" && ["canvas_apply_ops", "generate_media"].includes(String(event.payload.toolName)))))) {
|
||||
needsRefresh = true;
|
||||
}
|
||||
schedule();
|
||||
|
||||
@@ -10,6 +10,7 @@ export type RemoteUserDataSummary = {
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
revision?: number;
|
||||
};
|
||||
|
||||
export type AssetFolder = {
|
||||
@@ -36,7 +37,7 @@ export type RemoteUserDataSnapshot = {
|
||||
projects: CanvasProject[];
|
||||
};
|
||||
|
||||
export type CanvasLibrarySummary = Pick<CanvasProject, "id" | "projectId" | "title" | "createdAt" | "updatedAt"> & {
|
||||
export type CanvasLibrarySummary = Pick<CanvasProject, "id" | "projectId" | "title" | "revision" | "createdAt" | "updatedAt"> & {
|
||||
nodeCount: number;
|
||||
previewNodes: CanvasProject["nodes"];
|
||||
};
|
||||
@@ -117,9 +118,35 @@ export function getRemoteCanvasProject(id: string) {
|
||||
}
|
||||
|
||||
export function upsertRemoteCanvasProject(project: CanvasProject) {
|
||||
return http.put<{ project: RemoteUserDataSummary }>(`/canvas-projects/${encodeURIComponent(project.id)}`, { project });
|
||||
const { viewport: _viewport, remoteContentHash: _hash, ...content } = project;
|
||||
return http.put<{ project: RemoteUserDataSummary & { revision: number } }>(`/canvas-projects/${encodeURIComponent(project.id)}`, { project: content });
|
||||
}
|
||||
|
||||
export function deleteRemoteCanvasProject(id: string) {
|
||||
return http.delete<{ id: string }>(`/canvas-projects/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export type CanvasHistoryEntry = {
|
||||
id: string;
|
||||
canvasId: string;
|
||||
revision: number;
|
||||
title: string;
|
||||
nodeCount: number;
|
||||
connectionCount: number;
|
||||
payloadBytes: number;
|
||||
reason: "automatic" | "before_restore";
|
||||
createdAt: string;
|
||||
contentUpdatedAt: string;
|
||||
};
|
||||
|
||||
export function listCanvasHistory(id: string, signal?: AbortSignal) {
|
||||
return http.get<{ snapshots: CanvasHistoryEntry[]; currentRevision: number }>(`/canvas-projects/${encodeURIComponent(id)}/history`, { signal });
|
||||
}
|
||||
|
||||
export function getCanvasHistoryEntry(id: string, snapshotId: string, signal?: AbortSignal) {
|
||||
return http.get<{ snapshot: CanvasHistoryEntry; project: CanvasProject }>(`/canvas-projects/${encodeURIComponent(id)}/history/${encodeURIComponent(snapshotId)}`, { signal });
|
||||
}
|
||||
|
||||
export function restoreRemoteCanvasHistory(id: string, snapshotId: string, revision: number) {
|
||||
return http.post<{ project: RemoteUserDataSummary & { revision: number } }>(`/canvas-projects/${encodeURIComponent(id)}/history/${encodeURIComponent(snapshotId)}/restore`, { revision });
|
||||
}
|
||||
|
||||
40
web/src/services/canvas-sync-drafts.ts
Normal file
40
web/src/services/canvas-sync-drafts.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { localForageStorageForScope } from "@/lib/localforage-storage";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { sameCanvasContent } from "@/lib/canvas/canvas-content";
|
||||
import { withCanvasStorePersistenceLock, type CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { withGenerationArtifactCommitLock } from "@/services/generation-asset-repository";
|
||||
|
||||
export type CanvasSyncDraft = { id: string; savedAt: string; project: CanvasProject };
|
||||
const draftKey = (projectId: string) => `infinite-canvas:sync-drafts:${projectId}`;
|
||||
const draftIndexKey = "infinite-canvas:sync-draft-index";
|
||||
|
||||
export async function readAllCanvasSyncDrafts(scope = getActiveUserScope()) {
|
||||
const raw = await localForageStorageForScope(scope).getItem(draftIndexKey);
|
||||
const ids: string[] = raw ? JSON.parse(raw) : [];
|
||||
return (await Promise.all(ids.map((id) => readCanvasSyncDrafts(id, scope)))).flat();
|
||||
}
|
||||
|
||||
export async function readCanvasSyncDrafts(projectId: string, scope = getActiveUserScope()): Promise<CanvasSyncDraft[]> {
|
||||
const raw = await localForageStorageForScope(scope).getItem(draftKey(projectId));
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
}
|
||||
|
||||
export async function preserveCanvasSyncDraft(project: CanvasProject, scope = getActiveUserScope()) {
|
||||
// A separate record survives replacement of the active canvas and a browser reload.
|
||||
const snapshot = structuredClone(project);
|
||||
return withGenerationArtifactCommitLock(scope, () =>
|
||||
withCanvasStorePersistenceLock(scope, async () => {
|
||||
const drafts = await readCanvasSyncDrafts(snapshot.id, scope);
|
||||
if (!drafts.some((draft) => sameCanvasContent(draft.project, snapshot))) {
|
||||
const storage = localForageStorageForScope(scope);
|
||||
const rawIndex = await storage.getItem(draftIndexKey);
|
||||
const ids: string[] = rawIndex ? JSON.parse(rawIndex) : [];
|
||||
if (!ids.includes(snapshot.id)) await storage.setItem(draftIndexKey, JSON.stringify([...ids, snapshot.id]));
|
||||
drafts.push({ id: nanoid(), savedAt: new Date().toISOString(), project: snapshot });
|
||||
await storage.setItem(draftKey(snapshot.id), JSON.stringify(drafts));
|
||||
}
|
||||
return drafts.length;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { getMediaBlob } from "@/services/file-storage";
|
||||
import { getImageBlob } from "@/services/image-storage";
|
||||
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
|
||||
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, restoreRemoteCanvasHistory, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
|
||||
import { ApiError } from "@/services/api/request";
|
||||
import { canvasContentHash, sameCanvasContent } from "@/lib/canvas/canvas-content";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { preserveCanvasSyncDraft, readCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
|
||||
import { appQueryClient } from "@/lib/query-client";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
|
||||
import { parseAssetRecordList } from "@/lib/asset-record";
|
||||
@@ -13,7 +17,6 @@ import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
||||
import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
|
||||
import { repairMissingCanvasAssets, collectCanvasMediaAssetIds, rebindInconsistentCanvasAssets, type CanvasAssetRebindResult } from "@/services/canvas-asset-repair";
|
||||
import { canvasNodeToAsset } from "@/lib/canvas/canvas-node-asset";
|
||||
import { sameAgentCanvasContent } from "@/lib/canvas/agent-canvas-snapshot";
|
||||
import { applyAgentCanvasPatch, type AgentCanvasPatch } from "@/lib/canvas/agent-canvas-patch";
|
||||
|
||||
let activeRemoteUserId = "";
|
||||
@@ -44,34 +47,89 @@ export async function initializeRemoteUserDataSession(userId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadCanvasProjectForEditing(id: string) {
|
||||
const pending = remoteProjectLoadPromises.get(id);
|
||||
if (pending) return pending;
|
||||
export async function loadCanvasProjectForEditing(id: string, options: { latest?: boolean; historyRestore?: { snapshotId: string; revision: number }; onLoad?: (project: CanvasProject) => void } = {}) {
|
||||
const epoch = sessionEpoch;
|
||||
const request = withRemoteUserDataSyncExclusive(async () => {
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
|
||||
const local = useCanvasStore.getState().projects.find((project) => project.id === id);
|
||||
if (!activeRemoteUserId || verifiedProjects.has(id)) return local;
|
||||
const { project } = await getRemoteCanvasProject(id);
|
||||
await loadReferencedAssets(collectAssetIds(project));
|
||||
const current = useCanvasStore.getState().projects.find((candidate) => candidate.id === id);
|
||||
if (current && !sameEntitySnapshot(acknowledgedProjects.get(id), current)) {
|
||||
// 当前页面已经开始编辑本地缓存时,远端详情只建立新的冲突基线;
|
||||
// 保留当前画布内容,后续保存由当前打开的画布显式覆盖远端版本,
|
||||
// 避免旧缓存被永久卡在“自动重试但永远冲突”的状态。
|
||||
acknowledgedProjects.set(id, project);
|
||||
verifiedProjects.add(id);
|
||||
return current;
|
||||
requireRemoteUserDataBaseline();
|
||||
const scope = getActiveUserScope();
|
||||
if (options.historyRestore) {
|
||||
const local = useCanvasStore.getState().openProject(id);
|
||||
if (local) {
|
||||
const draftCount = await preserveCanvasSyncDraft(local, scope);
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { draftCount });
|
||||
}
|
||||
if (epoch !== sessionEpoch || getActiveUserScope() !== scope || useCanvasStore.getState().openProject(id) !== local) {
|
||||
throw new Error("本地内容仍在更新,请稍后再恢复历史版本");
|
||||
}
|
||||
const { snapshotId, revision } = options.historyRestore;
|
||||
try {
|
||||
const saved = await restoreRemoteCanvasHistory(id, snapshotId, revision);
|
||||
if (epoch !== sessionEpoch || getActiveUserScope() !== scope) throw new Error("账号已切换,请重新打开画布查看恢复结果");
|
||||
if (saved.project.revision !== revision + 1) throw new Error("恢复结果的版本无效,请重新核对云端内容");
|
||||
} catch (error) {
|
||||
if (epoch === sessionEpoch && getActiveUserScope() === scope && error instanceof ApiError && (error.status === 409 || error.status === 428)) {
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { phase: "conflict", message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
let remote: CanvasProject;
|
||||
try {
|
||||
remote = (await getRemoteCanvasProject(id)).project;
|
||||
} catch (error) {
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
|
||||
const local = useCanvasStore.getState().openProject(id);
|
||||
if (error instanceof ApiError && error.status === 404 && local?.revision === 0) {
|
||||
options.onLoad?.(local);
|
||||
return local;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
|
||||
await loadReferencedAssets(collectAssetIds(remote));
|
||||
const hash = await canvasContentHash(remote);
|
||||
if (epoch !== sessionEpoch || getActiveUserScope() !== scope) throw new Error("账号已切换,请重新打开画布");
|
||||
const local = useCanvasStore.getState().openProject(id);
|
||||
const cachedClean = local?.remoteContentHash && local.remoteContentHash === await canvasContentHash(local);
|
||||
const dirty = local && (!verifiedProjects.has(id) && incrementalSession ? !cachedClean : !sameCanvasContent(acknowledgedProjects.get(id), local));
|
||||
if (dirty && !sameCanvasContent(local, remote)) {
|
||||
const draftCount = await preserveCanvasSyncDraft(local, scope);
|
||||
if (epoch !== sessionEpoch || getActiveUserScope() !== scope || useCanvasStore.getState().openProject(id) !== local) throw new Error("画布仍在更新,已保留本地内容,请稍后再加载最新版本");
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { draftCount });
|
||||
if (!options.latest && !options.historyRestore && local.revision !== undefined) {
|
||||
if (local.revision !== remote.revision) {
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { phase: "conflict", message: "云端画布已有更新,本地修改已保留为草稿" });
|
||||
} else {
|
||||
// A cached draft is not an acknowledged cloud save. Once its
|
||||
// ancestor is verified, retain it as pending against that content.
|
||||
acknowledgedProjects.set(id, { ...remote, remoteContentHash: hash });
|
||||
verifiedProjects.add(id);
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { phase: "pending", message: "本地草稿等待保存到云端" });
|
||||
scheduleRemoteUserDataSync();
|
||||
}
|
||||
options.onLoad?.(local);
|
||||
return local;
|
||||
}
|
||||
}
|
||||
// Editing/generation may continue during the network request or draft write.
|
||||
// Never replace a newer local state which has not been backed up.
|
||||
if (epoch !== sessionEpoch || getActiveUserScope() !== scope || useCanvasStore.getState().openProject(id) !== local) {
|
||||
throw new Error("画布仍在更新,已保留本地内容,请稍后再加载最新版本");
|
||||
}
|
||||
const project = { ...remote, viewport: local?.viewport || remote.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: hash };
|
||||
// Align live editor refs synchronously before publishing the new revision.
|
||||
// A generation callback must never see old rendered nodes paired with a new revision.
|
||||
options.onLoad?.(project);
|
||||
acknowledgedProjects.set(id, project);
|
||||
verifiedProjects.add(id);
|
||||
useCanvasStore.setState((state) => ({ projects: [...state.projects.filter((candidate) => candidate.id !== id), project] }));
|
||||
useCanvasStore.setState((state) => ({ projects: local ? state.projects.map((item) => item.id === id ? project : item) : [...state.projects, project] }));
|
||||
await flushCanvasStorePersistence();
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", message: "已加载云端最新版本" });
|
||||
return project;
|
||||
});
|
||||
remoteProjectLoadPromises.set(id, request);
|
||||
const clearPending = () => {
|
||||
if (remoteProjectLoadPromises.get(id) === request) remoteProjectLoadPromises.delete(id);
|
||||
};
|
||||
const clearPending = () => { if (remoteProjectLoadPromises.get(id) === request) remoteProjectLoadPromises.delete(id); };
|
||||
void request.then(clearPending, clearPending);
|
||||
return request;
|
||||
}
|
||||
@@ -92,13 +150,28 @@ export async function refreshCanvasAfterAgent(id: string) {
|
||||
const { project } = await getRemoteCanvasProject(id);
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换");
|
||||
const current = useCanvasStore.getState().projects.find((candidate) => candidate.id === id);
|
||||
if (current && !sameAgentCanvasContent(acknowledgedProjects.get(id), current)) throw new Error("Agent 已更新服务端画布,但本地存在未同步编辑。已保留本地内容,请处理同步冲突后刷新。");
|
||||
if (current && sameEntitySnapshot(current, project)) return current;
|
||||
const projected = current ? { ...project, viewport: current.viewport } : project;
|
||||
for (const listener of agentCanvasListeners) listener(projected, current);
|
||||
const cachedDirty = current && incrementalSession && !verifiedProjects.has(id) && current.remoteContentHash !== await canvasContentHash(current);
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换");
|
||||
if (current && (cachedDirty || !sameCanvasContent(acknowledgedProjects.get(id), current))) {
|
||||
await preserveAgentConflict(current);
|
||||
throw new Error("Agent 已更新服务端画布,但本地存在未同步编辑。已保留本地草稿,请加载云端最新版本。");
|
||||
}
|
||||
if (current && current.revision === project.revision && sameCanvasContent(current, project)) return current;
|
||||
const projected = { ...project, viewport: current?.viewport || project.viewport, remoteContentHash: await canvasContentHash(project) };
|
||||
if (epoch !== sessionEpoch || useCanvasStore.getState().openProject(id) !== (current || null)) throw new Error("画布仍在更新,请重新同步 Agent 结果");
|
||||
try {
|
||||
if (!sameCanvasContent(current, projected)) {
|
||||
for (const listener of agentCanvasListeners) listener(projected, current);
|
||||
}
|
||||
} catch (error) {
|
||||
if (current) await preserveAgentConflict(current);
|
||||
throw error;
|
||||
}
|
||||
acknowledgedProjects.set(id, project);
|
||||
verifiedProjects.add(id);
|
||||
useCanvasStore.setState((state) => ({ projects: [...state.projects.filter((candidate) => candidate.id !== id), projected] }));
|
||||
await flushCanvasStorePersistence();
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", message: "已同步 Agent 画布结果" });
|
||||
return projected;
|
||||
});
|
||||
}
|
||||
@@ -110,19 +183,46 @@ export async function applyAgentCanvasPatches(id: string, patches: AgentCanvasPa
|
||||
const baseline = acknowledgedProjects.get(id);
|
||||
const current = useCanvasStore.getState().projects.find((project) => project.id === id);
|
||||
if (!baseline || !current) throw new Error("缺少画布同步基线,需要重新读取画布");
|
||||
const remote = patches.reduce(applyAgentCanvasPatch, baseline);
|
||||
const projected = patches.reduce(applyAgentCanvasPatch, current);
|
||||
if (projected !== current) {
|
||||
for (const listener of agentCanvasListeners) listener(projected, current);
|
||||
let remote = baseline;
|
||||
let projected = current;
|
||||
try {
|
||||
if (incrementalSession && !verifiedProjects.has(id) && baseline.remoteContentHash !== await canvasContentHash(baseline)) throw new Error("本地缓存尚未核对,请加载云端最新版本");
|
||||
if (current.revision !== baseline.revision || !Number.isSafeInteger(baseline.revision)) throw new Error("画布同步基线已变化");
|
||||
for (const patch of patches) {
|
||||
if (!Number.isSafeInteger(patch.revision) || !Number.isSafeInteger(patch.baseRevision)) throw new Error("Agent 增量缺少版本,请重新读取画布");
|
||||
if (patch.revision! <= remote.revision!) continue;
|
||||
if (patch.baseRevision !== remote.revision || patch.revision !== patch.baseRevision! + 1) throw new Error("Agent 画布增量版本不连续,请重新读取画布");
|
||||
remote = { ...applyAgentCanvasPatch(remote, patch), revision: patch.revision };
|
||||
projected = { ...applyAgentCanvasPatch(projected, patch), revision: patch.revision };
|
||||
}
|
||||
if (projected === current) return current;
|
||||
const hash = await canvasContentHash(remote);
|
||||
if (epoch !== sessionEpoch || useCanvasStore.getState().openProject(id) !== current) throw new Error("画布仍在更新,请重新同步 Agent 结果");
|
||||
remote = { ...remote, remoteContentHash: hash };
|
||||
projected = { ...projected, remoteContentHash: hash };
|
||||
if (!sameCanvasContent(projected, current)) {
|
||||
for (const listener of agentCanvasListeners) listener(projected, current);
|
||||
}
|
||||
} catch (error) {
|
||||
if (epoch === sessionEpoch) await preserveAgentConflict(useCanvasStore.getState().openProject(id) || current);
|
||||
throw error;
|
||||
}
|
||||
acknowledgedProjects.set(id, remote);
|
||||
verifiedProjects.add(id);
|
||||
if (projected === current) return current;
|
||||
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project.id === id ? projected : project) }));
|
||||
await flushCanvasStorePersistence();
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { phase: sameCanvasContent(remote, projected) ? "done" : "pending", message: "已同步 Agent 画布结果" });
|
||||
return projected;
|
||||
});
|
||||
}
|
||||
|
||||
async function preserveAgentConflict(project: CanvasProject) {
|
||||
useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "conflict", message: "云端画布已有更新,请保留草稿并加载最新版本" });
|
||||
const draftCount = await preserveCanvasSyncDraft(project);
|
||||
useSyncProgressStore.getState().setProjectProgress(project.id, { draftCount });
|
||||
}
|
||||
|
||||
async function waitForRemoteProjectLoads() {
|
||||
const pending = [...remoteProjectLoadPromises.values()];
|
||||
if (pending.length) await Promise.all(pending);
|
||||
@@ -184,7 +284,6 @@ const LOCAL_STORAGE_KEY_PATTERN = /^(image|video|audio|file|video-reference|audi
|
||||
export async function syncRemoteUserData(userId?: string | null) {
|
||||
// 登录/切换账号时,服务端快照建立新的远端基线;本地 IndexedDB 只负责首屏缓存,
|
||||
// 不能把服务端已经删除或当前用户无权访问的实体重新补回去。后续增量保存必须基于这份基线做冲突校验。
|
||||
let repairedCanvasAssets = false;
|
||||
await withRemoteUserDataSyncExclusive(async () => {
|
||||
incrementalSession = false;
|
||||
activeRemoteUserId = userId || "";
|
||||
@@ -199,30 +298,49 @@ export async function syncRemoteUserData(userId?: string | null) {
|
||||
// 登录只拉一次聚合快照。摘要列表再逐条请求详情会把 N 条数据放大成 2N+2 个请求,
|
||||
// 并且会在登录阶段同时触发大量媒体解析,任何一项失败都会污染登录结果。
|
||||
const snapshot = await getRemoteUserDataSnapshot();
|
||||
// 登录时服务端是实体真相。浏览器 IndexedDB 只作为首屏缓存,不能把服务端已删除的记录补回去。
|
||||
// 这里只替换结构化记录,不在登录阶段解析图片/视频/音频 URL;媒体由实际使用方按需解析。
|
||||
const snapshotAssets = parseAssetRecordList(snapshot.assets);
|
||||
useCanvasStore.getState().replaceProjects(snapshot.projects);
|
||||
useAssetStore.getState().replaceAssets(snapshotAssets);
|
||||
const repair = repairMissingCanvasAssets();
|
||||
repairedCanvasAssets = repair.createdAssets > 0 || repair.updatedProjects > 0;
|
||||
const localProjects = useCanvasStore.getState().projects;
|
||||
const remoteById = new Map(snapshot.projects.map((project) => [project.id, project]));
|
||||
// Archive unsynced work before replacing the active cache, including deleted remote canvases.
|
||||
for (const local of localProjects) {
|
||||
const clean = local.remoteContentHash && local.remoteContentHash === await canvasContentHash(local);
|
||||
if (!clean && !sameCanvasContent(local, remoteById.get(local.id))) await preserveCanvasSyncDraft(local);
|
||||
}
|
||||
const projects = await Promise.all(snapshot.projects.map(async (project) => {
|
||||
const local = localProjects.find((item) => item.id === project.id);
|
||||
const draftCount = (await readCanvasSyncDrafts(project.id)).length;
|
||||
useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "done", draftCount, message: "已保存到云端" });
|
||||
return { ...project, viewport: local?.viewport || project.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: await canvasContentHash(project) };
|
||||
}));
|
||||
if (useCanvasStore.getState().projects !== localProjects) throw new Error("本地画布仍在更新,已保留本地内容,请重新同步");
|
||||
useCanvasStore.getState().replaceProjects(projects);
|
||||
useAssetStore.getState().replaceAssets(parseAssetRecordList(snapshot.assets));
|
||||
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
|
||||
acknowledgedProjects = new Map(snapshot.projects.map((project) => [project.id, project]));
|
||||
acknowledgedAssets = new Map(snapshotAssets.map((asset) => [asset.id, asset]));
|
||||
acknowledgedProjects = new Map(projects.map((project) => [project.id, project]));
|
||||
acknowledgedAssets = new Map(parseAssetRecordList(snapshot.assets).map((asset) => [asset.id, asset]));
|
||||
remoteUserDataPhase = "ready";
|
||||
} catch (error) {
|
||||
remoteUserDataPhase = "failed";
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
if (repairedCanvasAssets) await saveRemoteUserDataNow();
|
||||
}
|
||||
|
||||
export function installRemoteUserDataAutoSync() {
|
||||
if (subscriptionsInstalled) return;
|
||||
subscriptionsInstalled = true;
|
||||
useCanvasStore.subscribe((state, previous) => {
|
||||
if (state.projects !== previous.projects) scheduleRemoteUserDataSync();
|
||||
if (state.projects === previous.projects) return;
|
||||
const before = new Map(previous.projects.map((project) => [project.id, project]));
|
||||
const changed = state.projects.filter((project) => !sameCanvasContent(before.get(project.id), project));
|
||||
if (!changed.length && state.projects.length === previous.projects.length) return;
|
||||
if (remoteUserDataPhase === "ready") {
|
||||
for (const project of changed) {
|
||||
if (sameCanvasContent(acknowledgedProjects.get(project.id), project)) continue;
|
||||
if (useSyncProgressStore.getState().syncingProjects[project.id]?.phase === "conflict") continue;
|
||||
useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "pending", message: "有修改等待保存" });
|
||||
}
|
||||
}
|
||||
scheduleRemoteUserDataSync();
|
||||
});
|
||||
useAssetStore.subscribe((state, previous) => {
|
||||
if (state.assets !== previous.assets) scheduleRemoteUserDataSync();
|
||||
@@ -281,6 +399,9 @@ export function scheduleRemoteUserDataSync() {
|
||||
|
||||
export function formatLocalSavedRemotePending(localAction: string, error: unknown): string {
|
||||
const detail = error instanceof Error && error.message.trim() ? error.message.trim() : "未知错误";
|
||||
if (error instanceof ApiError && (error.status === 409 || error.status === 428)) {
|
||||
return `${localAction},云端同步已暂停:${detail}。请保留草稿并加载最新版本。`;
|
||||
}
|
||||
return `${localAction},云端同步失败:${detail}。将自动重试。`;
|
||||
}
|
||||
|
||||
@@ -295,7 +416,7 @@ export async function createCanvasProjectWithRemoteSync(title: string, projectId
|
||||
if (initialContent) useCanvasStore.getState().updateProject(id, initialContent);
|
||||
if (!activeRemoteUserId) return { id, syncError: new Error("尚未建立云端同步会话") };
|
||||
try {
|
||||
await saveRemoteUserDataNow();
|
||||
await saveRemoteUserDataNow(id);
|
||||
return { id };
|
||||
} catch (syncError) {
|
||||
scheduleRemoteUserDataSync();
|
||||
@@ -436,35 +557,44 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveRemoteUserDataNow(options: { force?: boolean } = {}) {
|
||||
// 这是远端写入的总闸门:只有 phase=ready 且已建立 acknowledged 基线时才能提交。
|
||||
// 本地 Zustand/localForage 写成功不等于服务端写成功,任何同步异常都必须继续抛出给调用方。
|
||||
export async function saveRemoteUserDataNow(input?: string | readonly string[] | { force?: boolean }) {
|
||||
const projectId = typeof input === "string" || Array.isArray(input) ? input as string | readonly string[] : undefined;
|
||||
const options = input && typeof input === "object" && !Array.isArray(input) ? input as { force?: boolean } : {};
|
||||
const epoch = sessionEpoch;
|
||||
if (!activeRemoteUserId) return;
|
||||
if (!activeRemoteUserId) throw new Error("尚未建立云端同步会话,本地内容尚未保存到云端");
|
||||
requireRemoteUserDataBaseline();
|
||||
// 画布先用本地缓存秒开时,远端详情校验可能仍在进行;写入必须等待校验结果。
|
||||
const assertNoConflict = () => {
|
||||
const ids = projectId === undefined ? useCanvasStore.getState().projects.map((project) => project.id)
|
||||
: typeof projectId === "string" ? [projectId] : projectId;
|
||||
if (ids.some((id) => useSyncProgressStore.getState().syncingProjects[id]?.phase === "conflict")) {
|
||||
throw new ApiError("云端画布已有更新,请保留本地草稿并加载最新版本", { status: 409 });
|
||||
}
|
||||
};
|
||||
if (projectId !== undefined) assertNoConflict();
|
||||
await waitForRemoteProjectLoads();
|
||||
if (syncPromise) {
|
||||
syncQueued = true;
|
||||
return syncPromise;
|
||||
await syncPromise;
|
||||
assertNoConflict();
|
||||
return;
|
||||
}
|
||||
syncPromise = withRemoteUserDataSyncExclusive(async () => {
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存");
|
||||
requireRemoteUserDataBaseline();
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存");
|
||||
await drainRemoteUserDataChanges(options);
|
||||
});
|
||||
try {
|
||||
await syncPromise;
|
||||
assertNoConflict();
|
||||
} finally {
|
||||
syncPromise = null;
|
||||
if (syncQueued) scheduleRemoteUserDataSync();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制覆盖保存:以本地内容为准修复画布媒体与素材的绑定(重绑到引用同一资源的素材,
|
||||
* 缺失则按节点新建),再走「素材先于画布」的整体推送覆盖云端。服务端画布不变式只认
|
||||
* 「节点资源被其 assetId 对应素材引用」,重绑后的本地画布可以在不破坏该不变式的前提下
|
||||
* 覆盖远端。素材远端版本冲突时采纳远端为基线继续覆盖,避免显式覆盖被冲突检测卡死。
|
||||
* 修复媒体与素材的绑定,再按素材先于画布的顺序保存。
|
||||
* 素材修复可采用远端素材基线;画布始终携带原始 revision,不能绕过版本校验。
|
||||
*/
|
||||
export async function forceOverwriteRemoteCanvasSync(): Promise<CanvasAssetRebindResult> {
|
||||
const epoch = sessionEpoch;
|
||||
@@ -472,7 +602,7 @@ export async function forceOverwriteRemoteCanvasSync(): Promise<CanvasAssetRebin
|
||||
requireRemoteUserDataBaseline();
|
||||
await waitForRemoteProjectLoads();
|
||||
const rebind = await withRemoteUserDataSyncExclusive(async () => {
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止强制覆盖");
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存");
|
||||
requireRemoteUserDataBaseline();
|
||||
const projects = useCanvasStore.getState().projects;
|
||||
// 服务端素材记录是 guard 实际校验的事实;本地缓存可能落后,须先取回再判定绑定一致性。
|
||||
@@ -503,26 +633,15 @@ async function drainRemoteUserDataChanges(options: { force?: boolean } = {}) {
|
||||
async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: { force?: boolean } = {}) {
|
||||
// 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。
|
||||
// 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。
|
||||
const changedProjectIds = new Set(useCanvasStore.getState().projects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project)).map((project) => project.id));
|
||||
repairMissingCanvasAssets(incrementalSession ? changedProjectIds : undefined, incrementalSession);
|
||||
const changedProjectIds = new Set(useCanvasStore.getState().projects.filter((project) => !sameCanvasContent(acknowledgedProjects.get(project.id), project)).map((project) => project.id));
|
||||
repairMissingCanvasAssets(changedProjectIds, incrementalSession);
|
||||
const currentProjects = useCanvasStore.getState().projects;
|
||||
const currentAssets = useAssetStore.getState().assets;
|
||||
const dirtyProjects = currentProjects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project));
|
||||
const dirtyProjects = currentProjects.filter((project) => !sameCanvasContent(acknowledgedProjects.get(project.id), project) && useSyncProgressStore.getState().syncingProjects[project.id]?.phase !== "conflict");
|
||||
const dirtyAssets = currentAssets.filter((asset) => !sameEntitySnapshot(acknowledgedAssets.get(asset.id), asset));
|
||||
if (!dirtyProjects.length && !dirtyAssets.length) return;
|
||||
|
||||
if (incrementalSession) {
|
||||
for (const source of dirtyProjects) {
|
||||
const baseline = acknowledgedProjects.get(source.id);
|
||||
if (!baseline || verifiedProjects.has(source.id)) continue;
|
||||
const { project } = await getRemoteCanvasProject(source.id);
|
||||
if (Date.parse(project.updatedAt) !== Date.parse(baseline.updatedAt)) {
|
||||
// 以远端当前版本作为新的校验基线,继续提交当前打开画布的完整快照。
|
||||
// 这是画布编辑态的显式覆盖策略,避免资产同步被旧缓存冲突永久阻塞。
|
||||
acknowledgedProjects.set(source.id, project);
|
||||
}
|
||||
verifiedProjects.add(source.id);
|
||||
}
|
||||
for (const source of dirtyAssets) {
|
||||
const baseline = acknowledgedAssets.get(source.id);
|
||||
if (!baseline || verifiedAssets.has(source.id)) continue;
|
||||
@@ -546,24 +665,27 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
|
||||
acknowledgedAssets.set(source.id, source);
|
||||
verifiedAssets.add(source.id);
|
||||
}
|
||||
const errors: unknown[] = [];
|
||||
for (const source of dirtyProjects) {
|
||||
const keysToUpload = collectLocalMediaKeys(source);
|
||||
const total = keysToUpload.length;
|
||||
if (total > 0) {
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, {
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, {
|
||||
projectId: source.id,
|
||||
total,
|
||||
completed: 0,
|
||||
phase: "uploading",
|
||||
message: "正在同步媒体至云端",
|
||||
phase: total > 0 ? "uploading" : "saving",
|
||||
message: total > 0 ? "正在同步媒体至云端" : "正在保存画布",
|
||||
});
|
||||
}
|
||||
const onMediaUploaded = () => {
|
||||
if (total > 0) {
|
||||
useSyncProgressStore.getState().incrementProjectCompleted(source.id);
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (!Number.isSafeInteger(source.revision) || source.revision! < 0) {
|
||||
throw new ApiError("缺少画布版本,请保留草稿并加载云端最新版本", { status: 428 });
|
||||
}
|
||||
const hash = await canvasContentHash(source);
|
||||
const remotePayload = await ensureRemoteResourceReferences(source, uploaded, onMediaUploaded);
|
||||
if (total > 0) {
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, {
|
||||
@@ -571,20 +693,45 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
|
||||
message: "正在保存画布结构",
|
||||
});
|
||||
}
|
||||
await upsertRemoteCanvasProject(sanitizeCanvasProjectForRemoteSync(remotePayload));
|
||||
acknowledgedProjects.set(source.id, source);
|
||||
verifiedProjects.add(source.id);
|
||||
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 : "云端同步失败,等待重试",
|
||||
});
|
||||
const { project: saved } = await upsertRemoteCanvasProject(sanitizeCanvasProjectForRemoteSync(remotePayload));
|
||||
if (!Number.isSafeInteger(saved.revision) || saved.revision !== source.revision! + 1) {
|
||||
throw new ApiError("服务端未返回有效画布版本,请加载云端最新版本", { status: 409 });
|
||||
}
|
||||
throw error;
|
||||
const current = useCanvasStore.getState().openProject(source.id);
|
||||
if (current && current.revision !== source.revision) {
|
||||
throw new ApiError("画布基线已变化,请加载云端最新版本", { status: 409 });
|
||||
}
|
||||
const acknowledged = { ...source, revision: saved.revision, remoteContentHash: hash };
|
||||
acknowledgedProjects.set(source.id, acknowledged);
|
||||
verifiedProjects.add(source.id);
|
||||
if (current) {
|
||||
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project === current
|
||||
? { ...project, revision: saved.revision, remoteContentHash: hash } : project) }));
|
||||
}
|
||||
await flushCanvasStorePersistence();
|
||||
const pending = !sameCanvasContent(source, useCanvasStore.getState().openProject(source.id) || undefined);
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, { phase: pending ? "pending" : "done", message: pending ? "有新修改等待保存" : "已保存到云端" });
|
||||
if (pending) syncQueued = true;
|
||||
} catch (error) {
|
||||
const conflict = error instanceof ApiError && (error.status === 409 || error.status === 428);
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, {
|
||||
phase: conflict ? "conflict" : "error",
|
||||
message: error instanceof Error ? error.message : "云端同步失败,等待重试",
|
||||
});
|
||||
if (conflict) {
|
||||
try {
|
||||
const current = useCanvasStore.getState().openProject(source.id) || source;
|
||||
const draftCount = await preserveCanvasSyncDraft(current);
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, { draftCount });
|
||||
} catch (draftError) {
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, { message: "本地草稿保存失败,请勿关闭页面;请先下载草稿" });
|
||||
errors.push(draftError);
|
||||
}
|
||||
}
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
if (errors.length) throw errors[0];
|
||||
if (dirtyProjects.length) void appQueryClient.invalidateQueries({ queryKey: ["canvas-library"] });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
||||
import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
import { sameCanvasContent } from "@/lib/canvas/canvas-content";
|
||||
import { DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, readCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
|
||||
import { parseCanvasStorageDocument, rebaseCanvasProjects, serializeCanvasStorageDocument, type CanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision";
|
||||
import { localForageStorageForScope } from "@/lib/localforage-storage";
|
||||
@@ -14,6 +15,8 @@ import type { TimelineProject } from "@/types/timeline";
|
||||
|
||||
export type CanvasProject = {
|
||||
id: string;
|
||||
revision?: number;
|
||||
remoteContentHash?: string;
|
||||
projectId?: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
@@ -448,6 +451,7 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
const appearanceDefault = readCanvasAppearanceDefault();
|
||||
const project: CanvasProject = {
|
||||
id,
|
||||
revision: 0,
|
||||
projectId,
|
||||
title,
|
||||
createdAt: now,
|
||||
@@ -469,6 +473,7 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
const now = new Date().toISOString();
|
||||
const project: CanvasProject = {
|
||||
id: nanoid(),
|
||||
revision: 0,
|
||||
projectId: source.projectId,
|
||||
title: source.title || "导入画布",
|
||||
createdAt: source.createdAt || now,
|
||||
@@ -490,20 +495,27 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
openProject: (id) => {
|
||||
return get().projects.find((item) => item.id === id) || null;
|
||||
},
|
||||
renameProject: (id, title) =>
|
||||
set((state) => ({
|
||||
projects: state.projects.map((project) => (project.id === id ? { ...project, title: title.trim() || project.title, updatedAt: new Date().toISOString() } : project)),
|
||||
})),
|
||||
renameProject: (id, title) => set((state) => {
|
||||
const current = state.projects.find((project) => project.id === id);
|
||||
const nextTitle = title.trim() || current?.title;
|
||||
if (!current || current.title === nextTitle) return state;
|
||||
return { projects: state.projects.map((project) => project === current ? { ...project, title: nextTitle!, updatedAt: new Date().toISOString() } : project) };
|
||||
}),
|
||||
deleteProjects: (ids) =>
|
||||
set((state) => {
|
||||
const projects = state.projects.filter((project) => !ids.includes(project.id));
|
||||
return { projects };
|
||||
}),
|
||||
replaceProjects: (projects) => set({ projects }),
|
||||
updateProject: (id, patch) =>
|
||||
set((state) => ({
|
||||
projects: state.projects.map((project) => (project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project)),
|
||||
})),
|
||||
updateProject: (id, patch) => set((state) => {
|
||||
const current = state.projects.find((project) => project.id === id);
|
||||
if (!current) return state;
|
||||
const next = { ...current, ...patch };
|
||||
const contentChanged = !sameCanvasContent(current, next);
|
||||
if (!contentChanged && samePersistenceValue(current.viewport, next.viewport)) return state;
|
||||
if (contentChanged) next.updatedAt = new Date().toISOString();
|
||||
return { projects: state.projects.map((project) => project === current ? next : project) };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: CANVAS_STORE_KEY,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { cleanupUnusedImages, collectImageStorageKeys, resolveImageUrl, uploadIm
|
||||
import { cleanupUnusedMedia, collectMediaStorageKeys, resolveMediaUrl } from "@/services/file-storage";
|
||||
import { flushGenerationAssetStorageLocks, insertOrReturnGenerationAsset, withGenerationArtifactCommitLock, withGenerationAssetStorageLock } from "@/services/generation-asset-repository";
|
||||
import { CANVAS_STORE_KEY, commitPendingCanvasStorePersistenceLocked, pendingCanvasStorePersistence, withCanvasStorePersistenceLock } from "@/stores/canvas/use-canvas-store";
|
||||
import { readAllCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
|
||||
|
||||
export type AssetKind = "text" | "image" | "video" | "audio" | "model" | "entity";
|
||||
export type { AssetCategory } from "@/lib/asset-category";
|
||||
@@ -414,6 +415,7 @@ export const useAssetStore = create<AssetStore>()(
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
window.setTimeout(() =>
|
||||
withGenerationArtifactCommitLock(scope, async () => {
|
||||
const syncDrafts = await readAllCanvasSyncDrafts(scope);
|
||||
// 固定锁序:artifact -> Canvas(释放)-> Asset,避免跨 store 锁重入。
|
||||
const canvasProjects = await withCanvasStorePersistenceLock(scope, async () => {
|
||||
await commitPendingCanvasStorePersistenceLocked(scope);
|
||||
@@ -423,7 +425,7 @@ export const useAssetStore = create<AssetStore>()(
|
||||
await withGenerationAssetStorageLock(scope, async () => {
|
||||
await commitPendingAssetStorePersistenceLocked(scope);
|
||||
const durableAssets = (await readPersistedAssetDocumentForScope(scope)).state.assets;
|
||||
const references = { projects: canvasProjects, assets: durableAssets };
|
||||
const references = { projects: canvasProjects, assets: durableAssets, syncDrafts };
|
||||
const imageKeys = new Set([...frozenExtraImageKeys, ...collectImageStorageKeys(references)]);
|
||||
const mediaKeys = new Set([...frozenExtraMediaKeys, ...collectMediaStorageKeys(references)]);
|
||||
await cleanupUnusedImages(
|
||||
|
||||
@@ -4,8 +4,9 @@ export type SyncProjectProgress = {
|
||||
projectId: string;
|
||||
total: number;
|
||||
completed: number;
|
||||
phase: "uploading" | "saving" | "done" | "error";
|
||||
phase: "pending" | "uploading" | "saving" | "done" | "error" | "conflict";
|
||||
message?: string;
|
||||
draftCount?: number;
|
||||
};
|
||||
|
||||
type SyncProgressStore = {
|
||||
@@ -55,7 +56,7 @@ export const useSyncProgressStore = create<SyncProgressStore>((set, get) => ({
|
||||
clearAll: () => set({ syncingProjects: {} }),
|
||||
isAnySyncing: () => {
|
||||
const list = Object.values(get().syncingProjects);
|
||||
return list.some((item) => item.phase === "uploading" || item.phase === "saving");
|
||||
return list.some((item) => item.phase !== "done");
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user