Keep image migration pending until save confirmation

This commit is contained in:
2026-11-06 22:21:03 +08:00
parent 570cd53fc3
commit 776b665727
8 changed files with 23 additions and 17 deletions
+3
View File
@@ -69,3 +69,6 @@
- 数据库聚合确认父图含 9 个节点、2 条边、2 张内嵌图片。JSON 文本约 477,646 字节,图片 src 合计 474,073 字符,约占 99.3%。原先设计让两张图片阻塞整张画布的数据读取。
修复采用普通 personal SaaS 附件模式:一份原始附件、一份按需显示预览,快照仅引用附件。前端添加真实附件上传及显式旧图迁移;Core PR #15 提供单一 1024px 预览、权限检查和有界缓存。原图不替换,AI 继续取原始内容。图片独立加载后,结构读取不再受两张完整图片的传输时间阻塞。生产部署和指定画布迁移后的实际字节数/时间须重测后记录,不能把预计缩减当作已完成实测。
后端 PR #15 已由用户合并为 b2ae48e,分支/PR #936/#937、主线 #940、部署 #941 均成功。Endoleap main 570cd53 已接入。业务数据尚需用户明确点击“优化图片加载”;目前未把上传接口、预览上线或模拟测试当成该画布已经完成迁移/提速。迁移前聚合与哈希基线保存在服务器 `/tmp/endoleap-canvas-diag-20261106T131903Z/migration-baseline.json`,当前版本 252、8 个父图节点、2 张内嵌图片。预期在下一次明确操作后核对哈希和真实下载字节/时间。
+1 -1
View File
@@ -125,6 +125,6 @@ Headless Canvas API 的拆包方案保留在本地忽略的 `docs/HEADLESS_CANVA
## 图片附件与加载
真实画布新上传的图片存入 Foundry 附件 API,快照只保存原始附件引用,避免 Base64 随每次打开和保存传输。原图保留;画布按需读取单一最长边 1024px 的预览,放大后可读取原图。预览权限与原图一致,Core 的配套预览功能通过 PR #15 交付,部署前服务端仍可能返回原图。
真实画布新上传的图片存入 Foundry 附件 API,快照只保存原始附件引用,避免 Base64 随每次打开和保存传输。原图保留;画布按需读取单一最长边 1024px 的预览,放大后可读取原图。预览权限与原图一致,Core 的配套预览功能已随 PR #15 合并并部署(b2ae48e);小图或 GIF 可能直接返回原始文件。
已有内嵌图片通过画布提示中的“优化图片加载”明确迁移一次:上传原始字节,然后按当前版本保存相同节点、尺寸、位置与连线。打开页面不会自动迁移或 PATCH。只改变视角保存在本机;真实内容修改才自动保存到服务器。
+2 -1
View File
@@ -49,5 +49,6 @@
- 新图片使用受管附件;旧图仅在用户明确点击后迁移,字节保持原样,快照不保存预览地址。
- 单元测试增加字节一致性、重复图片去重、幂等重试、失败不替换、V1 兼容和渲染 URL 分离;共 84 项通过。
- DOM 回归确认只读打开/缩放零 PATCH、显式迁移一次上传、位置和边不变、画布使用预览而快照保存原图引用;原有 AI/子图/连线回归通过。
- 同步 TCP/数据库证据及隐私边界见 PERFORMANCE.md。Core PR #15 的最新远程检查、部署及用户画布迁移后实测仍在进行。
- 同步 TCP/数据库证据及隐私边界见 PERFORMANCE.md。Core PR #15 已由用户合并为 b2ae48e;分支/PR CI #936/#937、主线检查 #940 和部署 #941 全部成功。当前仍等待用户明确执行旧图片迁移与重开日志,尚未完成实际画布的提速验收。
- 最终前端检查:TypeScript、84 项测试、完整 DOM 回归、隔离 Webpack 构建和 API 冒烟均通过(包括 15.2 秒慢正文、原图/预览参数及响应头)。
- 最后补充:图片按需加载/异步解码;优化提示保持到版本保存确认后才消失,并阻止与新图片上传重叠。延迟保存的 DOM 回归和最终隔离构建通过。
+1 -1
View File
@@ -478,7 +478,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
<p>可通过右上角导出 JSON 备份。</p>
</div>
{!!snapshot && inlineImageCount(tree.rootDocument) > 0 && <div className={styles.imageMigration} role="status"><span>此画布的旧图片可优化加载,原图和内容都会保留。</span><button type="button" disabled={tree.busy || !!recovery} onClick={() => void tree.migrateImages().catch((error) => setError(errorText(error)))}>{tree.busy ? "正在处理…" : "优化图片加载"}</button></div>}
{!!snapshot && (inlineImageCount(tree.rootDocument) > 0 || tree.migratingImages) && <div className={styles.imageMigration} role="status"><span>此画布的旧图片可优化加载,原图和内容都会保留。</span><button type="button" disabled={tree.busy || imageUploading || !!recovery} onClick={() => void tree.migrateImages().catch((error) => setError(errorText(error)))}>{tree.busy ? "正在处理…" : "优化图片加载"}</button></div>}
{imageUploading && <p className={styles.imageMigration} role="status">正在上传图片…</p>}
<BranchMenu request={branchRequest} close={closeBranch} choose={chooseBranch} />
{iconPicker && <IconPicker close={() => setIconPicker(false)} choose={(name, label) => {
+1 -1
View File
@@ -26,7 +26,7 @@ export function MaterialNode(props: NodeProps<CanvasNode>) {
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} title={data.title} selected={selected} /></header>
<Handle type="target" position={Position.Left} className={styles.legacyHandle} isConnectable={false} />
{data.kind === "image" ? <img className={styles.image} src={managedImageSource(data.src, preview)} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
{data.kind === "image" ? <img loading="lazy" decoding="async" className={styles.image} src={managedImageSource(data.src, preview)} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
<CanvasTextEditor id={id} data={data} selected={selected} />
)}
<Handle type="source" position={Position.Right} className={styles.legacyHandle} isConnectable={false} />
+1 -1
View File
@@ -7,5 +7,5 @@ import styles from "./canvas.module.css";
export function PlainImageNode(props: NodeProps<CanvasNode>) {
const { zoom } = useViewport();
const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
return <TransformableNode {...props} className={styles.plainImageFrame}><img className={styles.plainImage} src={managedImageSource(props.data.src, preview)} alt={props.data.title} draggable={false} referrerPolicy="no-referrer" /></TransformableNode>;
return <TransformableNode {...props} className={styles.plainImageFrame}><img loading="lazy" decoding="async" className={styles.plainImage} src={managedImageSource(props.data.src, preview)} alt={props.data.title} draggable={false} referrerPolicy="no-referrer" /></TransformableNode>;
}
+11 -11
View File
@@ -276,19 +276,19 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
async function migrateImages() {
return exclusive(async () => {
await flush();
const before = current.current.get(root)!;
const controller = new AbortController(); migrationAbort.current = controller;
let document: CanvasDocument;
try { document = await prepareManagedImages(before, api, workspace, controller.signal); }
finally { migrationAbort.current = null; }
if (!mounted.current || controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
undo.current.push({ boardId: root, before }); redo.current = [];
publish(new Map(current.current).set(root, document));
const writer = writers.current.get(root)!;
await writer.enqueue(document); await writer.settled(); if (writer.error) throw new Error(writer.error);
try {
await flush();
const before = current.current.get(root)!;
const document = await prepareManagedImages(before, api, workspace, controller.signal);
if (!mounted.current || controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
undo.current.push({ boardId: root, before }); redo.current = [];
publish(new Map(current.current).set(root, document));
const writer = writers.current.get(root)!;
await writer.enqueue(document); await writer.settled(); if (writer.error) throw new Error(writer.error);
} finally { migrationAbort.current = null; }
});
}
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
return { documents, current, views, migrateImages, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !!undo.current.length, canRedo: !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
return { documents, current, views, migrateImages, migratingImages: !!migrationAbort.current, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !!undo.current.length, canRedo: !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
}
+3 -1
View File
@@ -24,7 +24,8 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
if(!suffix&&method==='GET')return json(structuredClone(snapshot));
if(!suffix&&method==='PATCH'){
if(options.headers['If-Match']!==`"${snapshot.version}"`)return json({error:'version_conflict'},412);
const payload=JSON.parse(options.body).document;assert.ok(payload.nodes.every(node=>!node.id.includes('::')),'No projected IDs may be persisted');
const payload=JSON.parse(options.body).document;
if(payload.nodes.some(node=>node.id==='legacy-image' && node.data.assetId))await new Promise(resolve=>setTimeout(resolve,200));assert.ok(payload.nodes.every(node=>!node.id.includes('::')),'No projected IDs may be persisted');
if(board===parent)assert.ok(!payload.nodes.some(node=>node.id==='child-text'),'Child content cannot be duplicated into parent');
saves.push({board,document:payload});snapshot.document=structuredClone(payload);snapshot.version++;return json({version:snapshot.version});
}
@@ -152,6 +153,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
console.log('PASS AI images: sticky mode, modality filtering, original-node asset output and cancellation');
const beforeMigration=structuredClone(records.get(parent).document);
await event([...document.querySelectorAll('button')].find(button=>button.textContent==='优化图片加载'),'click');
assert.ok([...document.querySelectorAll('button')].some(button=>button.textContent==='正在处理…'),'Migration stays visible until the versioned save is acknowledged');
await waitUntil(()=>records.get(parent).document.nodes.find(node=>node.id==='legacy-image')?.data.src.startsWith('https://api.foundry.entroia.com/'),'explicit image migration');
assert.equal(assetUploads,1);const migrated=records.get(parent).document.nodes.find(node=>node.id==='legacy-image');assert.deepEqual(migrated.position,beforeMigration.nodes.find(node=>node.id==='legacy-image').position);
assert.deepEqual(records.get(parent).document.edges,beforeMigration.edges);