feat(assets): 素材回收站与画布历史 - 合并 PR #370 并修复删除一致性

This commit is contained in:
ddCat committed 2026-09-01 18:50:30 +08:00
commit 39bdc54762
21 files changed
+1764 -340

No files matched your search

@@ -13,7 +13,23 @@ import { useAssetStore, type Asset } from "@/stores/use-asset-store";
const categoryLabels: Record<string, string> = { all: "全部资产", ...ASSET_CATEGORY_LABELS };
type ProjectPickerItem = { id: string; category: string; folderId?: string; project?: ProjectAsset; character?: ProjectAsset; media?: Asset };
export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", initialFolderId = "all", onClose, onInsert, onInsertFolder }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; initialFolderId?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void; onInsertFolder?: (folderId: string) => Promise<void> | void }) {
export function CanvasProjectAssetModal({
open,
detail,
initialCategory = "all",
initialFolderId = "all",
onClose,
onInsert,
onInsertFolder,
}: {
open: boolean;
detail?: ProjectDetail;
initialCategory?: string;
initialFolderId?: string;
onClose: () => void;
onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void;
onInsertFolder?: (folderId: string) => Promise<void> | void;
}) {
const mediaAssets = useAssetStore((state) => state.assets);
const externalAssetSources = useExternalAssetSources(open);
const items = useMemo<ProjectPickerItem[]>(() => {
@@ -21,38 +37,40 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
const projectItems = (detail?.assets || []).flatMap((asset): ProjectPickerItem[] => {
if (asset.category === "character" && asset.character) return [{ id: asset.id, category: "character", folderId: asset.folderId, project: asset, character: asset }];
const media = mediaById.get(asset.id);
if (media?.status === "archived") return [];
return asset.mediaType === "model" || asset.mediaType === "entity" ? [] : [{ id: asset.id, category: normalizeAssetCategory(asset.category || media?.category), folderId: asset.folderId, project: asset, media }];
});
if (detail) return projectItems;
// 自由画布未关联项目时回退到个人素材库。
return mediaAssets
.filter((asset) => asset.kind !== "model" && asset.kind !== "entity")
.map((media): ProjectPickerItem => ({ id: media.id, category: normalizeAssetCategory(media.category), media }));
return mediaAssets.filter((asset) => asset.kind !== "model" && asset.kind !== "entity" && asset.status !== "archived").map((media): ProjectPickerItem => ({ id: media.id, category: normalizeAssetCategory(media.category), media }));
}, [detail?.assets, mediaAssets]);
const localPickerItems = useMemo<AssetLibraryPickerItem[]>(() => items.map((item) => {
const character = item.character;
const project = item.project;
const media = item.media;
const coverRepresentation = character?.character?.representations.find((representation) => representation.role === "turnaround_sheet") || character?.character?.representations.find((representation) => representation.role === "primary") || character?.character?.representations.find((representation) => representation.role === "front");
const remoteResourceId = resourceIdFromStorageKey(project?.storageKey);
return {
id: item.id,
title: character?.title || project?.title || media?.title || "未命名资产",
category: item.category,
folderId: item.folderId,
kindLabel: character ? "角色卡" : (media?.kind || project?.mediaType) === "video" ? "视频" : (media?.kind || project?.mediaType) === "audio" ? "音频" : (media?.kind || project?.mediaType) === "text" ? "文本" : "图片",
asset: media,
imageUrl: coverRepresentation
? resourceFileUrl(coverRepresentation.resourceId)
: project?.mediaType === "image" && remoteResourceId
? resourceFileUrl(remoteResourceId)
: undefined,
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
imageFit: character ? "contain" : "cover",
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "),
};
}), [items]);
const localPickerItems = useMemo<AssetLibraryPickerItem[]>(
() =>
items.map((item) => {
const character = item.character;
const project = item.project;
const media = item.media;
const coverRepresentation =
character?.character?.representations.find((representation) => representation.role === "turnaround_sheet") ||
character?.character?.representations.find((representation) => representation.role === "primary") ||
character?.character?.representations.find((representation) => representation.role === "front");
const remoteResourceId = resourceIdFromStorageKey(project?.storageKey);
return {
id: item.id,
title: character?.title || project?.title || media?.title || "未命名资产",
category: item.category,
folderId: item.folderId,
kindLabel: character ? "角色卡" : (media?.kind || project?.mediaType) === "video" ? "视频" : (media?.kind || project?.mediaType) === "audio" ? "音频" : (media?.kind || project?.mediaType) === "text" ? "文本" : "图片",
asset: media,
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : project?.mediaType === "image" && remoteResourceId ? resourceFileUrl(remoteResourceId) : undefined,
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
imageFit: character ? "contain" : "cover",
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "),
};
}),
[items],
);
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => [...localPickerItems, ...externalAssetSources.items], [externalAssetSources.items, localPickerItems]);
return (
@@ -69,18 +87,27 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
emptyTitle="此分类没有可引用资产"
emptyDescription={detail ? "先在项目角色与资产中完成角色确认或素材关联。" : "当前为自由画布,可先在素材库添加内容。"}
footerNote={externalAssetSources.error || "角色引用会在生成时解析当前角色版本"}
onFolderAction={onInsertFolder ? async (folderId) => { await onInsertFolder(folderId); onClose(); } : undefined}
onFolderAction={
onInsertFolder
? async (folderId) => {
await onInsertFolder(folderId);
onClose();
}
: undefined
}
onClose={onClose}
onConfirm={async (ids) => {
const payloads = await Promise.all(ids.map(async (id) => {
const external = externalAssetSources.items.find((item) => item.id === id)?.external;
if (external) return externalAssetToInsertPayload(external);
const item = items.find((candidate) => candidate.id === id);
if (!item) throw new Error("所选资产已不存在,请重新选择");
if (item.media || item.character || !item.project) return toInsertPayload(item);
const { asset } = await getRemoteAsset(item.project.id);
return toInsertPayload({ ...item, media: asset });
}));
const payloads = await Promise.all(
ids.map(async (id) => {
const external = externalAssetSources.items.find((item) => item.id === id)?.external;
if (external) return externalAssetToInsertPayload(external);
const item = items.find((candidate) => candidate.id === id);
if (!item) throw new Error("所选资产已不存在,请重新选择");
if (item.media || item.character || !item.project) return toInsertPayload(item);
const { asset } = await getRemoteAsset(item.project.id);
return toInsertPayload({ ...item, media: asset });
}),
);
if (!payloads.length) return;
await onInsert(payloads);
onClose();
@@ -106,8 +133,30 @@ function toInsertPayload(item: ProjectPickerItem): InsertAssetPayload {
}
if (!asset) throw new Error("项目资产不可用");
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id };
if (asset.kind === "video") return { kind: "video", url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url), storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "audio") return { kind: "audio", url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url), storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "video")
return {
kind: "video",
url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url),
storageKey: asset.data.storageKey,
title: asset.title,
width: asset.data.width,
height: asset.data.height,
durationMs: asset.data.durationMs,
bytes: asset.data.bytes,
mimeType: asset.data.mimeType,
assetId: asset.id,
};
if (asset.kind === "audio")
return {
kind: "audio",
url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url),
storageKey: asset.data.storageKey,
title: asset.title,
durationMs: asset.data.durationMs,
bytes: asset.data.bytes,
mimeType: asset.data.mimeType,
assetId: asset.id,
};
if (asset.kind === "image") return { kind: "image", dataUrl: projectAssetMediaUrl(asset.data.storageKey, asset.data.dataUrl), storageKey: asset.data.storageKey, title: asset.title, assetId: asset.id };
throw new Error("当前项目资产不能直接插入画布");
}
@@ -121,7 +170,10 @@ export function projectCharacterToInsertPayload(asset: ProjectAsset): InsertAsse
if (!asset.character) throw new Error("项目角色信息不完整");
const card = asset.character;
const definition = card.definition;
const cover = card.representations.find((representation) => representation.role === "turnaround_sheet") || card.representations.find((representation) => representation.role === "primary") || card.representations.find((representation) => representation.role === "front");
const cover =
card.representations.find((representation) => representation.role === "turnaround_sheet") ||
card.representations.find((representation) => representation.role === "primary") ||
card.representations.find((representation) => representation.role === "front");
return {
kind: "character",
title: asset.title,
@@ -134,12 +186,14 @@ export function projectCharacterToInsertPayload(asset: ProjectAsset): InsertAsse
visualStatus: card.visualStatus,
voiceStatus: card.voiceStatus,
voiceName: card.voice?.profile.name,
voiceProfile: card.voice ? {
name: card.voice.profile.name,
provider: card.voice.profile.provider,
language: card.voice.profile.language,
timbre: card.voice.profile.timbre,
} : undefined,
voiceProfile: card.voice
? {
name: card.voice.profile.name,
provider: card.voice.profile.provider,
language: card.voice.profile.language,
timbre: card.voice.profile.timbre,
}
: undefined,
voiceInstructions: card.voice?.instructions,
};
}