fix(canvas): 画布删除 - 跳过关联素材预加载与合同校验

This commit is contained in:
ddCat
2026-09-19 08:43:19 +08:00
parent fecaab80a1
commit 5ad05efb58
3 changed files with 57 additions and 5 deletions

View File

@@ -5,6 +5,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
## 画布删除不再预加载关联素材
登录后的画布删除直接按 ID 请求服务端,不再先进入编辑加载流程,避免历史素材的 MIME 等字段校验阻塞删除。保留账号会话检查、服务端删除失败上抛及成功后清理本地缓存的顺序。
- [x] 26 项画布同步与素材合同专项测试通过;新增回归覆盖关联素材为 `image/*`、未缓存画布、重复 ID,以及批量删除部分失败后缓存与模拟服务端状态一致。
- [ ] 在真实账号下从画布列表和编辑页删除包含异常 MIME 素材的画布,刷新确认已删除;自动化验证使用模拟 API,未删除实际账号数据。
## 站点皮肤、控件焦点与外观分类
主按钮填充改为主题参数统一管理,经典黑白采用黑白基础色与 115° 紫蓝渐变;复制主题后可分别编辑明暗模式的纯色/渐变、角度、常态/悬停/按下双色和文字色。创作页“开始创作”与前后台主按钮使用相同参数,危险、幽灵和禁用按钮不套用渐变。后台隔离用户工作台外壳与浮层样式。输入、选择、日期和搜索控件移除重复焦点阴影,保留键盘焦点与错误状态。站点及外观按品牌、登录页、欢迎页、SEO、页尾备案、皮肤六个 Tab 展示,页面草稿统一保存,欢迎页开关继续独立即时保存。

View File

@@ -457,9 +457,7 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
const epoch = sessionEpoch;
const projectIds = [...new Set(ids.map((id) => id.trim()).filter(Boolean))];
if (!projectIds.length) return;
if (incrementalSession) {
for (const id of projectIds) await loadCanvasProjectForEditing(id);
}
// 删除只依赖画布 ID,跳过编辑加载,避免关联素材的合同校验阻塞删除。
await withRemoteUserDataSyncExclusive(async () => {
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新选择要删除的画布");
if (activeRemoteUserId) requireRemoteUserDataBaseline();

View File

@@ -4,7 +4,7 @@ import { apiClient } from "../src/services/api/request";
import { canvasContentHash } from "../src/lib/canvas/canvas-content";
import { rebaseCanvasProjects, parseCanvasStorageDocument } from "../src/lib/canvas/canvas-storage-revision";
import { readCanvasSyncDrafts } from "../src/services/canvas-sync-drafts";
import { applyAgentCanvasPatches, refreshCanvasAfterAgent, initializeRemoteUserDataSession, installRemoteUserDataAutoSync, loadCanvasProjectForEditing, resetRemoteUserDataSync, saveRemoteUserDataNow, syncRemoteUserData } from "../src/services/user-data-sync";
import { applyAgentCanvasPatches, deleteCanvasProjectsWithRemoteSync, refreshCanvasAfterAgent, initializeRemoteUserDataSession, installRemoteUserDataAutoSync, loadCanvasProjectForEditing, resetRemoteUserDataSync, saveRemoteUserDataNow, syncRemoteUserData } from "../src/services/user-data-sync";
import { createAgentCanvasSync } from "../src/services/agent-canvas-sync";
import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "../src/stores/canvas/use-canvas-store";
import { flushAssetStorePersistence, useAssetStore } from "../src/stores/use-asset-store";
@@ -22,6 +22,7 @@ let autoSave: (() => void) | undefined;
let remote = new Map<string, CanvasProject>();
let requests: Array<{ method: string; id: string; project?: CanvasProject }> = [];
let beforePut: (() => Promise<void>) | undefined;
let deleteFailureId: string | undefined;
function canvas(id = "canvas"): CanvasProject {
return {
@@ -52,6 +53,7 @@ beforeEach(async () => {
requests = [];
autoSave = undefined;
beforePut = undefined;
deleteFailureId = undefined;
remote = new Map([
["canvas", canvas()],
["other", canvas("other")],
@@ -82,7 +84,19 @@ beforeEach(async () => {
let data: unknown;
let status = 200;
if (id === "snapshot") data = { projects: structuredClone([...remote.values()]), assets: [] };
else if (id === "restore" && method === "post") {
else if (method === "delete") {
if (id === deleteFailureId) status = 403;
else {
remote.delete(id);
data = { id };
}
} else if (id === "batch" && method === "post") {
data = { assets: [{
id: "invalid-mime-asset", kind: "image", title: "历史素材", coverUrl: "", tags: [],
createdAt: "2026-09-01", updatedAt: "2026-09-01",
data: { dataUrl: "https://example.com/image.png", width: 100, height: 100, bytes: 1, mimeType: "image/*" },
}] };
} else if (id === "restore" && method === "post") {
const current = remote.get("canvas")!;
if (body.revision !== current.revision) status = 409;
else {
@@ -119,6 +133,39 @@ afterEach(async () => {
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
});
test("deletion skips editing and invalid MIME assets, including an uncached canvas", async () => {
const project = canvas();
project.nodes[0].metadata = { assetId: "invalid-mime-asset" };
remote.set(project.id, project);
await initializeRemoteUserDataSession(scope);
await expect(loadCanvasProjectForEditing(project.id)).rejects.toThrow("具体 MIME");
useCanvasStore.getState().deleteProjects(["other"]);
await flushCanvasStorePersistence();
requests = [];
await deleteCanvasProjectsWithRemoteSync([" canvas ", "canvas", "other"]);
expect(requests.map(({ method, id }) => ({ method, id }))).toEqual([
{ method: "delete", id: "canvas" }, { method: "delete", id: "other" },
]);
expect(remote.size).toBe(0);
expect(useCanvasStore.getState().projects).toEqual([]);
await useCanvasStore.persist.rehydrate();
expect(useCanvasStore.getState().projects).toEqual([]);
});
test("batch deletion retains failed canvases and persists successful deletions", async () => {
await initializeRemoteUserDataSession(scope);
deleteFailureId = "other";
await expect(deleteCanvasProjectsWithRemoteSync(["canvas", "other"])).rejects.toThrow();
expect([...remote.keys()]).toEqual(["other"]);
expect(useCanvasStore.getState().projects.map((project) => project.id)).toEqual(["other"]);
await useCanvasStore.persist.rehydrate();
expect(useCanvasStore.getState().projects.map((project) => project.id)).toEqual(["other"]);
});
test("stale viewport, no-op restore and open do not submit old content", async () => {
const old = useCanvasStore.getState().openProject("canvas")!;
remote.set("canvas", { ...addNode(canvas(), "afternoon-video"), revision: 2 });