diff --git a/PERFORMANCE.md b/PERFORMANCE.md
index 9a624ac..b87c052 100644
--- a/PERFORMANCE.md
+++ b/PERFORMANCE.md
@@ -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 张内嵌图片。预期在下一次明确操作后核对哈希和真实下载字节/时间。
diff --git a/README.md b/README.md
index 2c43a6a..47e598d 100644
--- a/README.md
+++ b/README.md
@@ -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。只改变视角保存在本机;真实内容修改才自动保存到服务器。
diff --git a/VALIDATION.md b/VALIDATION.md
index b92158b..7671967 100644
--- a/VALIDATION.md
+++ b/VALIDATION.md
@@ -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 回归和最终隔离构建通过。
diff --git a/src/components/canvas/graph-canvas.tsx b/src/components/canvas/graph-canvas.tsx
index 171e5ac..47a32e3 100644
--- a/src/components/canvas/graph-canvas.tsx
+++ b/src/components/canvas/graph-canvas.tsx
@@ -478,7 +478,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。
可通过右上角导出 JSON 备份。
- {!!snapshot && inlineImageCount(tree.rootDocument) > 0 && 此画布的旧图片可优化加载,原图和内容都会保留。
}
+ {!!snapshot && (inlineImageCount(tree.rootDocument) > 0 || tree.migratingImages) && 此画布的旧图片可优化加载,原图和内容都会保留。
}
{imageUploading && 正在上传图片…
}
{iconPicker && setIconPicker(false)} choose={(name, label) => {
diff --git a/src/components/canvas/material-node.tsx b/src/components/canvas/material-node.tsx
index a18e333..12fb013 100644
--- a/src/components/canvas/material-node.tsx
+++ b/src/components/canvas/material-node.tsx
@@ -26,7 +26,7 @@ export function MaterialNode(props: NodeProps) {
- {data.kind === "image" ?
: (
+ {data.kind === "image" ?
: (
)}
diff --git a/src/components/canvas/plain-image-node.tsx b/src/components/canvas/plain-image-node.tsx
index 3a774bb..9474a6f 100644
--- a/src/components/canvas/plain-image-node.tsx
+++ b/src/components/canvas/plain-image-node.tsx
@@ -7,5 +7,5 @@ import styles from "./canvas.module.css";
export function PlainImageNode(props: NodeProps) {
const { zoom } = useViewport();
const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
- return
;
+ return
;
}
diff --git a/src/components/canvas/use-canvas-tree.ts b/src/components/canvas/use-canvas-tree.ts
index 059ffe4..30254ed 100644
--- a/src/components/canvas/use-canvas-tree.ts
+++ b/src/components/canvas/use-canvas-tree.ts
@@ -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 };
}
diff --git a/tests/canvas-v2-scenario.mjs b/tests/canvas-v2-scenario.mjs
index 107f690..b515f60 100644
--- a/tests/canvas-v2-scenario.mjs
+++ b/tests/canvas-v2-scenario.mjs
@@ -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);