feat(assets&canvas): 实现素材回收站机制、自动定时清理策略与过期时间提示,增加画布创建与变更历史时间线

This commit is contained in:
Danborad
2026-09-01 14:37:26 +08:00
parent dca252434b
commit 867a18a948
17 changed files with 1411 additions and 321 deletions

View File

@@ -7,6 +7,7 @@ import { flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-st
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
let activeRemoteUserId = "";
type RemoteUserDataPhase = "inactive" | "hydrating" | "ready" | "failed";
@@ -128,6 +129,12 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
if (!projectIds.length) return;
await withRemoteUserDataSyncExclusive(async () => {
if (activeRemoteUserId) requireRemoteUserDataBaseline();
const currentProjects = useCanvasStore.getState().projects;
const deletedProjectObjects = currentProjects.filter((p) => projectIds.includes(p.id));
if (deletedProjectObjects.length > 0) {
useCanvasHistoryStore.getState().recordDeletedProjects(deletedProjectObjects);
}
for (const id of projectIds) {
if (activeRemoteUserId) {
await deleteRemoteCanvasProject(id);
@@ -137,7 +144,7 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
// 批量删除允许部分成功;每个已成功远端删除的实体都立即落实到本地 durable cache。
await flushCanvasStorePersistence();
}
// 清理仅属于被删除画布的自动生成素材
// 将属于被删除画布的所有媒体节点安全归档至素材库回收站 (status = "archived")
const currentAssets = useAssetStore.getState().assets;
const remainingProjects = useCanvasStore.getState().projects;
const activeAssetIds = new Set<string>();
@@ -146,24 +153,118 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
if (node.metadata?.assetId) activeAssetIds.add(node.metadata.assetId);
}
}
const assetsToRemove = currentAssets.filter((asset) => {
let assetChanged = false;
// 1. 已存在的关联素材标记为 archived
const assetsToArchive = currentAssets.filter((asset) => {
const canvasId = asset.metadata?.canvasId as string | undefined;
const source = asset.metadata?.source as string | undefined;
return canvasId && projectIds.includes(canvasId) && source === "canvas-generation" && !activeAssetIds.has(asset.id);
return canvasId && projectIds.includes(canvasId) && !activeAssetIds.has(asset.id) && asset.status !== "archived";
});
for (const asset of assetsToRemove) {
if (activeRemoteUserId) {
try {
await deleteRemoteAsset(asset.id);
acknowledgedAssets.delete(asset.id);
} catch {
// 忽略远端删除失败,继续本地清理
for (const asset of assetsToArchive) {
useAssetStore.getState().updateAsset(asset.id, { status: "archived" });
assetChanged = true;
}
// 2. 对于画布中尚未入库的媒体节点,直接归档为回收站素材
for (const project of deletedProjectObjects) {
for (const node of project.nodes || []) {
const isMedia = node.type === "image" || node.type === "video" || node.type === "audio";
const content = typeof node.metadata?.content === "string" ? node.metadata.content : "";
const storageKey = typeof node.metadata?.storageKey === "string" ? node.metadata.storageKey : undefined;
if (!isMedia || (!content && !storageKey)) continue;
const existingAsset = node.metadata?.assetId ? currentAssets.find((a) => a.id === node.metadata?.assetId) : undefined;
if (existingAsset) {
if (existingAsset.status !== "archived") {
useAssetStore.getState().updateAsset(existingAsset.id, { status: "archived" });
assetChanged = true;
}
continue;
}
const title = node.title || `${project.title} - ${node.type === "image" ? "图片" : node.type === "video" ? "视频" : "音频"}`;
const prompt = typeof node.metadata?.prompt === "string" ? node.metadata.prompt : "";
if (node.type === "image") {
useAssetStore.getState().addAsset({
kind: "image",
title,
coverUrl: content || "",
tags: prompt ? [prompt.slice(0, 16)] : ["画布生成"],
category: "other",
status: "archived",
source: `已删除画布:${project.title}`,
data: {
dataUrl: content || "",
storageKey,
width: Number(node.width) || 1024,
height: Number(node.height) || 1024,
bytes: Number(node.metadata?.bytes) || 0,
mimeType: (node.metadata?.mimeType as string) || "image/png",
},
metadata: {
canvasId: project.id,
sourceNodeId: node.id,
},
});
assetChanged = true;
} else if (node.type === "video") {
useAssetStore.getState().addAsset({
kind: "video",
title,
coverUrl: content || "",
tags: prompt ? [prompt.slice(0, 16)] : ["画布视频"],
category: "other",
status: "archived",
source: `已删除画布:${project.title}`,
data: {
url: content || "",
storageKey,
width: Number(node.width) || 1280,
height: Number(node.height) || 720,
durationMs: Number(node.metadata?.durationMs) || 0,
bytes: Number(node.metadata?.bytes) || 0,
mimeType: (node.metadata?.mimeType as string) || "video/mp4",
},
metadata: {
canvasId: project.id,
sourceNodeId: node.id,
},
});
assetChanged = true;
} else if (node.type === "audio") {
useAssetStore.getState().addAsset({
kind: "audio",
title,
coverUrl: "",
tags: ["画布音频"],
category: "other",
status: "archived",
source: `已删除画布:${project.title}`,
data: {
url: content || "",
storageKey,
durationMs: Number(node.metadata?.durationMs) || 0,
bytes: Number(node.metadata?.bytes) || 0,
mimeType: (node.metadata?.mimeType as string) || "audio/mpeg",
},
metadata: {
canvasId: project.id,
sourceNodeId: node.id,
},
});
assetChanged = true;
}
}
await useAssetStore.getState().removeAsset(asset.id);
}
if (assetsToRemove.length > 0) {
if (assetChanged) {
await flushAssetStorePersistence();
if (activeRemoteUserId) {
try {
await drainRemoteUserDataChanges();
} catch (syncErr) {
console.warn("回收站素材云端同步警告:", syncErr);
}
}
}
});
}
@@ -272,11 +373,7 @@ function collectLocalMediaKeys(value: unknown, set = new Set<string>()): string[
return [...set];
}
async function ensureRemoteResourceReferences<T>(
value: T,
uploaded = new Map<string, string>(),
onUploaded?: () => void,
): Promise<T> {
async function ensureRemoteResourceReferences<T>(value: T, uploaded = new Map<string, string>(), onUploaded?: () => void): Promise<T> {
if (!value || typeof value !== "object") return value;
if (Array.isArray(value)) {
const result: unknown[] = [];