fix(*): 素材库与画布 - 修复批量删除刷新、选择布局及工具引用
This commit is contained in:
1 parent
4e0b0a9256
commit
0146a232a7
27 files changed
+852
-109
No files matched your search
@@ -13,7 +13,7 @@ import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
|
||||
import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { deleteAssetWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { deleteAssetsWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
|
||||
export type AssetPickerMediaKind = "image" | "video" | "audio" | "text";
|
||||
|
||||
@@ -291,14 +291,13 @@ export function AssetLibraryPickerModal({
|
||||
if (!archivedSelectedIds.length) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
for (const id of archivedSelectedIds) await deleteAssetWithRemoteSync(id);
|
||||
await deleteAssetsWithRemoteSync(archivedSelectedIds);
|
||||
setSelected(new Set());
|
||||
message.success(`已彻底删除 ${archivedSelectedIds.length} 个素材`);
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "删除失败");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
if (remoteEnabled) void remoteQuery.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -307,7 +306,7 @@ export function AssetLibraryPickerModal({
|
||||
if (!toDelete.length) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
for (const item of toDelete) await deleteAssetWithRemoteSync(item.id);
|
||||
await deleteAssetsWithRemoteSync(toDelete.map((item) => item.id));
|
||||
setSelected(new Set());
|
||||
message.success(`已删除${remoteEnabled ? "当前页" : "回收站"} ${toDelete.length} 个素材`);
|
||||
setCategory("all");
|
||||
@@ -315,7 +314,6 @@ export function AssetLibraryPickerModal({
|
||||
message.error(err instanceof Error ? err.message : "清空回收站失败");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
if (remoteEnabled) void remoteQuery.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -532,7 +530,7 @@ export function AssetLibraryPickerModal({
|
||||
<div className="asset-picker-actions">
|
||||
{isRecycleBin ? (
|
||||
<>
|
||||
<Popconfirm title={remoteEnabled ? "确认删除当前页回收站素材?" : "确认清空回收站?"} description="仅删除当前列表中的素材;仍被引用的素材由服务端拒绝删除。删除不可恢复。" onConfirm={handleEmptyRecycleBin} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
<Popconfirm title={remoteEnabled ? "确认删除当前页回收站素材?" : "确认清空回收站?"} description="仅删除当前列表中的素材;关联文件会直接释放,原画布或任务中的旧引用可能失效。删除不可恢复。" onConfirm={handleEmptyRecycleBin} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
<Button type="text" danger disabled={working || !archivedCount}>
|
||||
{remoteEnabled ? "删除当前页" : "清空回收站"}
|
||||
</Button>
|
||||
|
||||
@@ -90,6 +90,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
|
||||
const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? "";
|
||||
const [prompt, setPrompt] = useState(savedPrompt);
|
||||
const promptRef = useRef(savedPrompt);
|
||||
const [presetOpen, setPresetOpen] = useState(false);
|
||||
const [expandedPresetOpen, setExpandedPresetOpen] = useState(false);
|
||||
const [nineGridOpen, setNineGridOpen] = useState(false);
|
||||
@@ -213,6 +214,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
const canAutoMention = autoMentionedPrompt !== prompt;
|
||||
|
||||
useEffect(() => {
|
||||
promptRef.current = normalizedSavedPrompt;
|
||||
setPrompt(normalizedSavedPrompt);
|
||||
if (normalizedSavedPrompt !== savedPrompt) onPromptChange(node.id, normalizedSavedPrompt);
|
||||
}, [node.id, normalizedSavedPrompt, onPromptChange, savedPrompt]);
|
||||
@@ -258,6 +260,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
const skillReferences = useMemo(() => resolvedMentionReferences.filter((item) => item.kind === "skill"), [resolvedMentionReferences]);
|
||||
|
||||
const updatePrompt = (value: string) => {
|
||||
promptRef.current = value;
|
||||
setPrompt(value);
|
||||
onPromptChange(node.id, value);
|
||||
if (showPromptTemplates && /(^|\s)\/[\p{L}\p{N}_-]*$/u.test(value)) {
|
||||
@@ -266,6 +269,10 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
}
|
||||
};
|
||||
|
||||
const updatePromptFromCurrent = (updater: (currentPrompt: string) => string) => {
|
||||
updatePrompt(updater(promptRef.current));
|
||||
};
|
||||
|
||||
const applyPreset = (preset: CanvasPromptPreset) => {
|
||||
const withoutSlash = prompt.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, "$1").trimEnd();
|
||||
updatePrompt(withoutSlash ? `${withoutSlash}\n${preset.prompt}` : preset.prompt);
|
||||
@@ -285,7 +292,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
updatePrompt(trimmedBase ? `${trimmedBase} ${insertText}` : insertText);
|
||||
};
|
||||
|
||||
const removeMotionToolMention = () => updatePrompt(removeToolMentions(prompt, "motion"));
|
||||
const removeMotionToolMention = () => updatePromptFromCurrent((currentPrompt) => removeToolMentions(currentPrompt, "motion"));
|
||||
|
||||
const submit = () => {
|
||||
const text = prompt.trim();
|
||||
@@ -330,11 +337,11 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
<div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1">
|
||||
{!simpleMode && (mode === "image" || mode === "video") ? <div className="canvas-node-tool-controls canvas-node-tool-controls-inline flex items-center gap-1" data-canvas-no-zoom data-canvas-wheel-scroll onPointerDown={e => e.stopPropagation()}>
|
||||
{mode === "image" ? <>
|
||||
<CanvasChooseImageStylePicker open={expanded ? expandedStyleToolOpen : styleToolOpen} onOpenChange={expanded ? setExpandedStyleToolOpen : setStyleToolOpen} activeToolId={activeStyleTool?.toolId} activeLabel={activeStyleTool?.label} onSelect={(id,label) => updatePrompt(applyToolMention(prompt,{id,label,type:"style"},"Palette"))} onClear={() => updatePrompt(removeToolMentions(prompt,"style"))} />
|
||||
<CanvasNineGridPicker open={expanded ? expandedNineGridOpen : nineGridOpen} onOpenChange={expanded ? setExpandedNineGridOpen : setNineGridOpen} icon={activeNineGridIcon} onSelect={(id,label,icon) => updatePrompt(applyToolMention(prompt,{id,label,type:"nine_grid"},icon))} />
|
||||
<CanvasChooseImageStylePicker open={expanded ? expandedStyleToolOpen : styleToolOpen} onOpenChange={expanded ? setExpandedStyleToolOpen : setStyleToolOpen} activeToolId={activeStyleTool?.toolId} activeLabel={activeStyleTool?.label} onSelect={(id,label) => updatePromptFromCurrent((currentPrompt) => applyToolMention(currentPrompt,{id,label,type:"style"},"Palette"))} onClear={() => updatePromptFromCurrent((currentPrompt) => removeToolMentions(currentPrompt,"style"))} />
|
||||
<CanvasNineGridPicker open={expanded ? expandedNineGridOpen : nineGridOpen} onOpenChange={expanded ? setExpandedNineGridOpen : setNineGridOpen} icon={activeNineGridIcon} onSelect={(id,label,icon) => updatePromptFromCurrent((currentPrompt) => applyToolMention(currentPrompt,{id,label,type:"nine_grid"},icon))} />
|
||||
</> : <>
|
||||
<CanvasChooseEffectPicker open={expanded ? expandedEffectToolOpen : effectToolOpen} onOpenChange={expanded ? setExpandedEffectToolOpen : setEffectToolOpen} activeToolId={activeEffectTool?.toolId} activeLabel={activeEffectTool?.label} onSelect={(id,label) => updatePrompt(applyToolMention(prompt,{id,label,type:"effect"},"Sparkles"))} onClear={() => updatePrompt(removeToolMentions(prompt,"effect"))} />
|
||||
<CanvasChooseMotionPicker open={expanded ? expandedMotionToolOpen : motionToolOpen} onOpenChange={expanded ? setExpandedMotionToolOpen : setMotionToolOpen} activeToolIds={activeMotionTools.map(t => t.toolId)} activeLabel={activeMotionTool?.label} onSelect={(id,label) => updatePrompt(applyToolMention(prompt,{id,label,type:"motion"},"Camera"))} onClear={removeMotionToolMention} />
|
||||
<CanvasChooseEffectPicker open={expanded ? expandedEffectToolOpen : effectToolOpen} onOpenChange={expanded ? setExpandedEffectToolOpen : setEffectToolOpen} activeToolId={activeEffectTool?.toolId} activeLabel={activeEffectTool?.label} onSelect={(id,label) => updatePromptFromCurrent((currentPrompt) => applyToolMention(currentPrompt,{id,label,type:"effect"},"Sparkles"))} onClear={() => updatePromptFromCurrent((currentPrompt) => removeToolMentions(currentPrompt,"effect"))} />
|
||||
<CanvasChooseMotionPicker open={expanded ? expandedMotionToolOpen : motionToolOpen} onOpenChange={expanded ? setExpandedMotionToolOpen : setMotionToolOpen} activeToolIds={activeMotionTools.map(t => t.toolId)} activeLabel={activeMotionTool?.label} onSelect={(id,label) => updatePromptFromCurrent((currentPrompt) => applyToolMention(currentPrompt,{id,label,type:"motion"},"Camera"))} onClear={removeMotionToolMention} />
|
||||
</>}
|
||||
</div> : null}
|
||||
{showPromptTemplates ? <CanvasPresetPicker mode={mode} skillReferences={skillReferences} open={expanded ? expandedPresetOpen : presetOpen} onOpenChange={expanded ? setExpandedPresetOpen : setPresetOpen} onSelect={applyPreset} dense appearance="quiet" /> : null}
|
||||
|
||||
@@ -52,11 +52,12 @@ const TOOL_REF_PATTERN = /@\[tool:(\w+):(\d+):([^:\]]+):([^\]]+)\]/g;
|
||||
/** 从文本中解析所有 @[tool:type:ID:label:icon] 令牌,返回去重的工具引用。 */
|
||||
export function parseToolMentionTokens(text: string): { type: string; toolId: number; label: string; icon: string }[] {
|
||||
const results: { type: string; toolId: number; label: string; icon: string }[] = [];
|
||||
const seen = new Set<number>();
|
||||
const seen = new Set<string>();
|
||||
for (const match of text.matchAll(TOOL_REF_PATTERN)) {
|
||||
const toolId = Number(match[2]);
|
||||
if (seen.has(toolId)) continue;
|
||||
seen.add(toolId);
|
||||
const identity = `${match[1]}:${toolId}`;
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
let label = match[3];
|
||||
try { label = decodeURIComponent(label); } catch { /* Keep readable text for malformed imported labels. */ }
|
||||
results.push({ type: match[1], toolId, label, icon: match[4] });
|
||||
@@ -585,8 +586,8 @@ function skillResourceText(node: CanvasNodeData) {
|
||||
|
||||
export function buildToolMentionReference(toolId: number, label: string, type: string, icon: string): CanvasResourceReference {
|
||||
return {
|
||||
id: `tool:${toolId}`,
|
||||
nodeId: `tool:${toolId}`,
|
||||
id: `tool:${type}:${toolId}`,
|
||||
nodeId: `tool:${type}:${toolId}`,
|
||||
kind: "tool",
|
||||
label,
|
||||
title: label,
|
||||
|
||||
@@ -26,7 +26,7 @@ import { uploadMediaFile } from "@/services/file-storage";
|
||||
import { flushAssetStorePersistence, useAssetStore, type Asset, type AssetCategory, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
|
||||
import { exportAssets, readAssetPackage } from "./asset-transfer";
|
||||
import { AssetStorageUsage, assetStorageUsageQueryKey } from "./asset-storage-usage";
|
||||
import { deleteAssetWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { deleteAssetWithRemoteSync, deleteAssetsWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { createAssetFolder, deleteAssetFolder, listAssetFolders, listRemoteAssetsPage, moveRemoteAssetsToFolder, updateAssetFolder, type AssetFolder } from "@/services/api/user-data";
|
||||
import { AssetBatchUploadModal } from "./asset-batch-upload-modal";
|
||||
@@ -487,9 +487,7 @@ export default function AssetsPage() {
|
||||
const count = trashAssets.length;
|
||||
if (!count) return;
|
||||
try {
|
||||
for (const asset of trashAssets) {
|
||||
await deleteAssetWithRemoteSync(asset.id);
|
||||
}
|
||||
await deleteAssetsWithRemoteSync(trashAssets.map((asset) => asset.id));
|
||||
setSelectedIds([]);
|
||||
message.success(`已彻底清空回收站 ${count} 个素材`);
|
||||
} catch (error) {
|
||||
@@ -516,7 +514,7 @@ export default function AssetsPage() {
|
||||
const confirmBatchDelete = async () => {
|
||||
if (!selectedAssets.length) return;
|
||||
try {
|
||||
for (const asset of selectedAssets) await deleteAssetWithRemoteSync(asset.id);
|
||||
await deleteAssetsWithRemoteSync(selectedAssets.map((asset) => asset.id));
|
||||
message.success(`已彻底删除 ${selectedAssets.length} 个素材`);
|
||||
setSelectedIds([]);
|
||||
setBatchDeleteOpen(false);
|
||||
@@ -982,7 +980,7 @@ export default function AssetsPage() {
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
>
|
||||
确定彻底删除「{deletingAsset?.title}」吗?未被其他内容引用的服务器本地或对象存储文件也会同步删除,操作不可恢复。
|
||||
确定彻底删除「{deletingAsset?.title}」吗?未被其他素材复用的服务器文件会直接释放,原画布或任务中的旧引用可能失效,操作不可恢复。
|
||||
</Modal>
|
||||
<Modal
|
||||
className="library-modal library-confirm-modal"
|
||||
@@ -994,7 +992,7 @@ export default function AssetsPage() {
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
>
|
||||
确定彻底删除已选择的 {selectedAssets.length} 个素材吗?未被复用的服务器文件会同步删除,操作不可恢复。
|
||||
确定彻底删除已选择的 {selectedAssets.length} 个素材吗?未被其他素材复用的服务器文件会直接释放,原画布或任务中的旧引用可能失效,操作不可恢复。
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -359,11 +359,12 @@ export function useCanvasRenderModel({
|
||||
const text = node.metadata?.composerContent ?? node.metadata?.prompt ?? "";
|
||||
const tokens = parseToolMentionTokens(text);
|
||||
if (!tokens.length) continue;
|
||||
const seen = new Set<number>();
|
||||
const seen = new Set<string>();
|
||||
const refs: ReturnType<typeof buildToolMentionReference>[] = [];
|
||||
for (const { type, toolId, label, icon } of tokens) {
|
||||
if (seen.has(toolId)) continue;
|
||||
seen.add(toolId);
|
||||
const identity = `${type}:${toolId}`;
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
refs.push(buildToolMentionReference(toolId, label, type, icon));
|
||||
}
|
||||
if (refs.length) map.set(node.id, refs);
|
||||
|
||||
@@ -109,6 +109,10 @@ export function deleteRemoteAsset(id: string) {
|
||||
return http.delete<{ id: string }>(`/assets/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export function deleteRemoteAssets(ids: string[]) {
|
||||
return http.post<{ ids: string[] }>("/assets/batch-delete", ids);
|
||||
}
|
||||
|
||||
export function listRemoteCanvasProjects() {
|
||||
return http.get<{ projects: RemoteUserDataSummary[] }>("/canvas-projects");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getMediaBlob } from "@/services/file-storage";
|
||||
import { getImageBlob } from "@/services/image-storage";
|
||||
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, restoreRemoteCanvasHistory, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
|
||||
import { deleteRemoteAssets, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, restoreRemoteCanvasHistory, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
|
||||
import { ApiError } from "@/services/api/request";
|
||||
import { canvasContentHash, sameCanvasContent } from "@/lib/canvas/canvas-content";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
@@ -438,19 +438,34 @@ export async function createCanvasProjectWithRemoteSync(title: string, projectId
|
||||
}
|
||||
|
||||
export async function deleteAssetWithRemoteSync(id: string) {
|
||||
return deleteAssetsWithRemoteSync([id]);
|
||||
}
|
||||
|
||||
export async function deleteAssetsWithRemoteSync(ids: string[]) {
|
||||
const epoch = sessionEpoch;
|
||||
const assetId = id.trim();
|
||||
if (!assetId) throw new Error("素材 ID 不能为空");
|
||||
if (!ids.length || ids.length > 1000) throw new Error("每次请选择 1–1000 个素材删除");
|
||||
const assetIds = [...new Set(ids.map((id) => id.trim()))];
|
||||
if (assetIds.some((id) => !id)) throw new Error("素材 ID 不能为空");
|
||||
await withRemoteUserDataSyncExclusive(async () => {
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新选择要删除的素材");
|
||||
if (activeRemoteUserId) {
|
||||
requireRemoteUserDataBaseline();
|
||||
await deleteRemoteAsset(assetId);
|
||||
acknowledgedAssets.delete(assetId);
|
||||
await deleteRemoteAssets(assetIds);
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请刷新原账号素材库确认删除结果");
|
||||
for (const id of assetIds) acknowledgedAssets.delete(id);
|
||||
}
|
||||
await useAssetStore.getState().removeAsset(assetId);
|
||||
await useAssetStore.getState().removeAssets(assetIds);
|
||||
await flushAssetStorePersistence();
|
||||
});
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请刷新原账号素材库确认删除结果");
|
||||
// 列表查询也会进入同步队列,必须在退出删除队列后触发,不能在队列内等待刷新。
|
||||
// 刷新慢或失败不应阻塞确认弹窗关闭,也不能把已完成的删除报告为失败。
|
||||
void Promise.all([
|
||||
appQueryClient.invalidateQueries({ queryKey: ["asset-library"] }, { throwOnError: true }),
|
||||
appQueryClient.invalidateQueries({ queryKey: ["asset-picker"] }, { throwOnError: true }),
|
||||
]).catch((error) => {
|
||||
console.warn("素材删除后列表刷新失败", error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
|
||||
|
||||
@@ -59,6 +59,7 @@ type AssetStore = {
|
||||
addGenerationAsset: (effectKey: string, asset: NewAsset, signal?: AbortSignal) => Promise<string>;
|
||||
updateAsset: (id: string, patch: Partial<Omit<Asset, "id" | "createdAt">>) => void;
|
||||
removeAsset: (id: string) => Promise<void>;
|
||||
removeAssets: (ids: string[]) => Promise<void>;
|
||||
replaceAssets: (assets: Asset[]) => void;
|
||||
cleanupImages: (extra?: unknown) => Promise<void>;
|
||||
};
|
||||
@@ -393,18 +394,23 @@ export const useAssetStore = create<AssetStore>()(
|
||||
set((state) => ({
|
||||
assets: state.assets.map((asset) => (asset.id === id ? parseAssetRecord({ ...asset, ...patch, updatedAt: new Date().toISOString() }) : asset)),
|
||||
})),
|
||||
removeAsset: async (id) => {
|
||||
removeAsset: async (id) => get().removeAssets([id]),
|
||||
removeAssets: async (ids) => {
|
||||
const removedIds = new Set(ids);
|
||||
let remainingAssets: Asset[] = [];
|
||||
let removedAsset: Asset | undefined;
|
||||
let hasLocalMedia = false;
|
||||
set((state) => {
|
||||
removedAsset = state.assets.find((asset) => asset.id === id);
|
||||
const assets = state.assets.filter((asset) => asset.id !== id);
|
||||
const assets = state.assets.filter((asset) => {
|
||||
if (!removedIds.has(asset.id)) return true;
|
||||
hasLocalMedia ||= !!collectImageStorageKeys(asset).size || !!collectMediaStorageKeys(asset).size;
|
||||
return false;
|
||||
});
|
||||
remainingAssets = assets;
|
||||
return { assets };
|
||||
});
|
||||
// 没有本地媒体定位时没有需要由该删除动作回收的 Blob;跳过全库扫描,
|
||||
// 避免纯文本/远程资源删除依赖浏览器 IndexedDB 驱动。
|
||||
if (!removedAsset || (!collectImageStorageKeys(removedAsset).size && !collectMediaStorageKeys(removedAsset).size)) return;
|
||||
if (!hasLocalMedia) return;
|
||||
await get().cleanupImages({ assets: remainingAssets });
|
||||
},
|
||||
replaceAssets: (assets) => set({ assets: assets.map(parseAssetRecord) }),
|
||||
|
||||
+11
-18
@@ -14049,9 +14049,10 @@ html:not(.dark) .creation-history-drawer-root {
|
||||
.assets-cover-media { width: 100%; height: 100%; object-fit: contain; background: color-mix(in srgb, var(--foreground) 5%, transparent); }
|
||||
.assets-cover-fallback { display: grid; width: 100%; height: 100%; place-items: center; background: color-mix(in srgb, var(--foreground) 4%, transparent); color: color-mix(in srgb, var(--foreground) 42%, transparent); }
|
||||
.assets-cover-vignette { display: none; }
|
||||
.assets-cover-badges { position: absolute; top: 8px; left: 8px; z-index: 2; display: flex; align-items: center; gap: 5px; pointer-events: none; }
|
||||
.assets-cover-badges { position: absolute; top: 8px; left: 38px; right: 44px; z-index: 2; display: flex; align-items: center; gap: 5px; overflow: hidden; white-space: nowrap; pointer-events: none; }
|
||||
.assets-cover-badge {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -14088,11 +14089,11 @@ html:not(.dark) .creation-history-drawer-root {
|
||||
.assets-cover-play svg { position: relative; width: 17px; height: 17px; margin-left: 2px; filter: drop-shadow(0 1px 4px rgba(0, 0, 0, .45)); }
|
||||
.assets-cover-link:hover .assets-cover-play, .assets-cover-link:focus-visible .assets-cover-play { opacity: 1; }
|
||||
|
||||
/* 选中态:accent 勾选框(右下)+ accent 描边由 is-selected 提供 */
|
||||
/* 选择入口固定在左上角,徽章预留其空间;选中描边由 is-selected 提供。 */
|
||||
.assets-select-check {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
z-index: 3;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
@@ -14167,17 +14168,19 @@ html:not(.dark) .creation-history-drawer-root {
|
||||
.assets-cover-model > svg { width: 34px; height: 34px; stroke-width: 1.1; }
|
||||
.assets-cover-model > span { max-width: 76%; overflow: hidden; font-size: var(--fs-micro); text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* 批量操作条:浮现动画,黑白表面 */
|
||||
/* 批量操作条随列表正常排版,留出选中描边和 hover 位移的空间。 */
|
||||
.assets-batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: 2px 0 0;
|
||||
margin: 2px 0 var(--space-4);
|
||||
padding: 8px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
|
||||
border-radius: var(--r-lg);
|
||||
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||
background: color-mix(in srgb, var(--surface-card) 82%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: var(--elevation-hairline);
|
||||
animation: assets-batch-in 240ms var(--motion-ease-out) both;
|
||||
}
|
||||
@@ -14185,7 +14188,7 @@ html:not(.dark) .creation-history-drawer-root {
|
||||
@media (prefers-reduced-motion: reduce) { .assets-batch-bar { animation: none; } }
|
||||
.assets-batch-count { display: inline-flex; align-items: baseline; gap: 6px; margin-right: auto; color: color-mix(in srgb, var(--foreground) 56%, transparent); font-size: var(--fs-label); }
|
||||
.assets-batch-count strong { color: var(--foreground); font-size: var(--fs-body); font-weight: 620; font-variant-numeric: tabular-nums; }
|
||||
.assets-batch-actions { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.assets-batch-actions { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }
|
||||
.assets-batch-actions .ant-btn { border: 0; border-radius: var(--r-md); background: transparent; color: color-mix(in srgb, var(--foreground) 56%, transparent); box-shadow: none; }
|
||||
.assets-batch-actions .ant-btn:hover { background: color-mix(in srgb, var(--foreground) 7%, transparent); color: var(--foreground); }
|
||||
|
||||
@@ -14284,16 +14287,6 @@ html:not(.dark) .creation-history-drawer-root {
|
||||
/* 1. 欢迎卡与 banner 同列宽,边缘对齐 */
|
||||
.assets-empty-cards { width: min(720px, calc(100% - 24px)); }
|
||||
|
||||
/* 2. 批量操作条:sticky + 毛玻璃,滚动长列表时始终可见 */
|
||||
.assets-batch-bar {
|
||||
position: sticky;
|
||||
top: 12px;
|
||||
z-index: 20;
|
||||
background: color-mix(in srgb, var(--surface-card) 82%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* 素材密度由页面传入卡片最小宽度,网格布局统一在 workspace-product.css。 */
|
||||
.assets-library-page .library-create-title { margin-top: 12px; font-size: var(--fs-body); }
|
||||
.assets-library-page .library-create-meta { margin-top: 4px; font-size: var(--fs-label); }
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||
import { QueryObserver } from "@tanstack/react-query";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { apiClient } from "@/services/api/request";
|
||||
import { deleteAssetsWithRemoteSync, initializeRemoteUserDataSession, loadAssetLibraryPage, resetRemoteUserDataSync } from "@/services/user-data-sync";
|
||||
import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { appQueryClient } from "@/lib/query-client";
|
||||
|
||||
const originalAdapter = apiClient.defaults.adapter;
|
||||
async function within<T>(promise: Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([promise, new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("删除或刷新链路未结束")), 1000);
|
||||
})]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
const asset = (id: string): Asset => ({
|
||||
id, kind: "text", title: id, coverUrl: "", tags: [],
|
||||
createdAt: "2026-09-21T00:00:00.000Z", updatedAt: "2026-09-21T00:00:00.000Z",
|
||||
data: { content: "测试素材" },
|
||||
} as Asset);
|
||||
|
||||
beforeEach(async () => {
|
||||
useAssetStore.setState({ assets: [] });
|
||||
await flushAssetStorePersistence();
|
||||
await initializeRemoteUserDataSession("batch-test-user");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await appQueryClient.cancelQueries();
|
||||
appQueryClient.clear();
|
||||
apiClient.defaults.adapter = originalAdapter;
|
||||
resetRemoteUserDataSync();
|
||||
useAssetStore.setState({ assets: [] });
|
||||
await flushAssetStorePersistence();
|
||||
});
|
||||
|
||||
describe("素材批量删除", () => {
|
||||
test("100 个素材只发一个 POST 数组请求,成功后只更新一次本地素材集合", async () => {
|
||||
const ids = Array.from({ length: 100 }, (_, i) => `asset-${i}`);
|
||||
useAssetStore.setState({ assets: [...ids.map(asset), asset("keep")] });
|
||||
const requests: unknown[] = [];
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
requests.push({ method: config.method, url: config.url, body: JSON.parse(config.data) });
|
||||
expect(useAssetStore.getState().assets).toHaveLength(101);
|
||||
return { config, headers: {}, status: 200, statusText: "OK", data: { code: 0, data: { ids }, msg: "ok" } };
|
||||
};
|
||||
let updates = 0;
|
||||
const stop = useAssetStore.subscribe((state, previous) => { if (state.assets !== previous.assets) updates++; });
|
||||
try {
|
||||
await deleteAssetsWithRemoteSync(ids);
|
||||
expect(requests).toEqual([{ method: "post", url: "/assets/batch-delete", body: ids }]);
|
||||
expect(useAssetStore.getState().assets.map((item) => item.id)).toEqual(["keep"]);
|
||||
expect(updates).toBe(1);
|
||||
} finally { stop(); }
|
||||
});
|
||||
|
||||
test.each([false, true])("真实分页查询刷新不阻塞删除完成(刷新失败:%s)", async (failRefresh) => {
|
||||
const initialPage = {
|
||||
assets: [asset("first"), asset("keep")], total: 2, page: 1, pageSize: 20,
|
||||
kindCounts: {}, categoryCounts: {}, folderCounts: {}, hasMore: false,
|
||||
};
|
||||
useAssetStore.setState({ assets: initialPage.assets });
|
||||
let releaseRefresh!: () => void;
|
||||
const refreshGate = new Promise<void>((resolve) => { releaseRefresh = resolve; });
|
||||
const requests: string[] = [];
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
requests.push(`${config.method} ${config.url}`);
|
||||
if (config.method === "post") {
|
||||
return { config, headers: {}, status: 200, statusText: "OK", data: { code: 0, data: { ids: ["first"] }, msg: "ok" } };
|
||||
}
|
||||
await refreshGate;
|
||||
if (failRefresh) throw new Error("列表暂时不可用");
|
||||
return { config, headers: {}, status: 200, statusText: "OK", data: {
|
||||
code: 0, data: { ...initialPage, assets: [asset("keep")], total: 1 }, msg: "ok",
|
||||
} };
|
||||
};
|
||||
const warning = spyOn(console, "warn").mockImplementation(() => {});
|
||||
const observers = ["asset-library", "asset-picker"].map((key) => new QueryObserver(appQueryClient, {
|
||||
queryKey: [key, "delete-regression"],
|
||||
queryFn: () => loadAssetLibraryPage({ page: 1, pageSize: 20 }),
|
||||
initialData: initialPage,
|
||||
}));
|
||||
const stops: (() => void)[] = [];
|
||||
const refreshed = observers.map((observer) => new Promise<void>((resolve) => {
|
||||
stops.push(observer.subscribe((result) => {
|
||||
if (result.fetchStatus === "idle" && (result.isError || result.data?.total === 1)) resolve();
|
||||
}));
|
||||
}));
|
||||
try {
|
||||
// GET 尚未返回,删除必须已返回,调用方才能关闭 Modal / Popconfirm。
|
||||
await within(deleteAssetsWithRemoteSync(["first"]));
|
||||
expect(useAssetStore.getState().assets.map((item) => item.id)).toEqual(["keep"]);
|
||||
expect(requests).toEqual(["post /assets/batch-delete", "get /assets", "get /assets"]);
|
||||
for (const observer of observers) expect(observer.getCurrentResult().fetchStatus).toBe("fetching");
|
||||
|
||||
releaseRefresh();
|
||||
// 真实 loadAssetLibraryPage 要再次进入同步队列;不能与删除互相等待。
|
||||
await within(Promise.all(refreshed));
|
||||
for (const observer of observers) {
|
||||
const result = observer.getCurrentResult();
|
||||
if (failRefresh) expect(result.error?.message).toBe("列表暂时不可用");
|
||||
else expect(result.data?.assets.map((item) => item.id)).toEqual(["keep"]);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
if (failRefresh) expect(warning).toHaveBeenCalledWith("素材删除后列表刷新失败", expect.any(Error));
|
||||
expect(requests.filter((request) => request.startsWith("post"))).toHaveLength(1);
|
||||
} finally {
|
||||
releaseRefresh();
|
||||
await appQueryClient.cancelQueries();
|
||||
stops.forEach((stop) => stop());
|
||||
observers.forEach((observer) => observer.destroy());
|
||||
warning.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("业务失败不会先删本地素材或回退到逐条请求", async () => {
|
||||
useAssetStore.setState({ assets: [asset("first"), asset("second")] });
|
||||
let requests = 0;
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
requests++;
|
||||
return { config, headers: {}, status: 200, statusText: "OK", data: { code: 400, data: null, msg: "拒绝删除", reason: "invalid_argument" } };
|
||||
};
|
||||
await expect(deleteAssetsWithRemoteSync(["first", "second"])).rejects.toThrow("拒绝删除");
|
||||
expect(requests).toBe(1);
|
||||
expect(useAssetStore.getState().assets.map((item) => item.id)).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
test("删除响应返回前切换账号,不删除新账号本地素材", async () => {
|
||||
useAssetStore.setState({ assets: [asset("same-id")] });
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
resetRemoteUserDataSync();
|
||||
useAssetStore.setState({ assets: [asset("same-id")] });
|
||||
return { config, headers: {}, status: 200, statusText: "OK", data: { code: 0, data: { ids: ["same-id"] }, msg: "ok" } };
|
||||
};
|
||||
await expect(deleteAssetsWithRemoteSync(["same-id"])).rejects.toThrow("账号已切换");
|
||||
expect(useAssetStore.getState().assets).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("空 ID 不发请求,重复 ID 合并到一个请求", async () => {
|
||||
const bodies: string[][] = [];
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
const ids = JSON.parse(config.data);
|
||||
bodies.push(ids);
|
||||
return { config, headers: {}, status: 200, statusText: "OK", data: { code: 0, data: { ids }, msg: "ok" } };
|
||||
};
|
||||
await expect(deleteAssetsWithRemoteSync(["good", " "])).rejects.toThrow("素材 ID 不能为空");
|
||||
expect(bodies).toHaveLength(0);
|
||||
await deleteAssetsWithRemoteSync([" first ", "first", "second"]);
|
||||
expect(bodies).toEqual([["first", "second"]]);
|
||||
});
|
||||
|
||||
test("素材页和选择器的批量及清空入口使用数组同步方法", () => {
|
||||
const page = readFileSync(new URL("../src/pages/assets/index.tsx", import.meta.url), "utf8");
|
||||
const picker = readFileSync(new URL("../src/components/assets/asset-library-picker-modal.tsx", import.meta.url), "utf8");
|
||||
expect(page).toContain("deleteAssetsWithRemoteSync(trashAssets.map");
|
||||
expect(page).toContain("deleteAssetsWithRemoteSync(selectedAssets.map");
|
||||
expect(picker).toContain("deleteAssetsWithRemoteSync(archivedSelectedIds)");
|
||||
expect(picker).toContain("deleteAssetsWithRemoteSync(toDelete.map");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,27 @@ import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
describe("assets page batch toolbar", () => {
|
||||
const styles = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
|
||||
|
||||
test("keeps card selection in the top left with badge clearance", () => {
|
||||
const checkbox = styles.match(/\.assets-select-check\s*\{([^}]+)\}/)?.[1] ?? "";
|
||||
const badges = styles.match(/\.assets-cover-badges\s*\{([^}]+)\}/)?.[1] ?? "";
|
||||
expect(checkbox).toContain("left: 8px;");
|
||||
expect(checkbox).toContain("top: 8px;");
|
||||
expect(checkbox).not.toMatch(/(?:right|bottom):/);
|
||||
expect(badges).toContain("left: 38px;");
|
||||
expect(badges).toContain("right: 44px;");
|
||||
});
|
||||
|
||||
test("keeps batch actions in document flow with space above selected cards", () => {
|
||||
const toolbarRules = [...styles.matchAll(/\.assets-batch-bar\s*\{([^}]+)\}/g)].map((match) => match[1]).join("\n");
|
||||
expect(toolbarRules).not.toMatch(/position:\s*(?:sticky|fixed|absolute)/);
|
||||
expect(toolbarRules).not.toContain("z-index:");
|
||||
expect(toolbarRules).toContain("margin: 2px 0 var(--space-4);");
|
||||
const actions = styles.match(/\.assets-batch-actions\s*\{([^}]+)\}/)?.[1] ?? "";
|
||||
expect(actions).toContain("flex-wrap: wrap;");
|
||||
});
|
||||
|
||||
test("places select all before cancel selection", () => {
|
||||
const source = readFileSync(resolve(import.meta.dir, "../src/pages/assets/index.tsx"), "utf8");
|
||||
const selectAllIndex = source.search(/>\s*全选\s*<\/Button>/);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { applyToolMention, parseToolMentionTokens, removeToolMentions } from "../src/lib/canvas/canvas-resource-references";
|
||||
import { applyToolMention, buildToolMentionReference, parseToolMentionTokens, removeToolMentions } from "../src/lib/canvas/canvas-resource-references";
|
||||
import { createNineGridNode } from "../src/lib/canvas/canvas-image-source";
|
||||
import { CanvasNodeType } from "../src/types/canvas";
|
||||
import type { CanvasNodeData } from "../src/types/canvas";
|
||||
@@ -55,6 +55,17 @@ test("tool labels round trip; style replaces, motion deduplicates; clear preserv
|
||||
expect(parseToolMentionTokens(combined)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("tool references with the same numeric id remain distinct across types", () => {
|
||||
let prompt = applyToolMention("", { id: 7, type: "style", label: "水墨" }, "Palette");
|
||||
prompt = applyToolMention(prompt, { id: 7, type: "motion", label: "推镜" }, "Camera");
|
||||
expect(parseToolMentionTokens(prompt).map(({ type, toolId, label }) => ({ type, toolId, label }))).toEqual([
|
||||
{ type: "style", toolId: 7, label: "水墨" },
|
||||
{ type: "motion", toolId: 7, label: "推镜" },
|
||||
]);
|
||||
expect(buildToolMentionReference(7, "水墨", "style", "Palette").id)
|
||||
.not.toBe(buildToolMentionReference(7, "推镜", "motion", "Camera").id);
|
||||
});
|
||||
|
||||
test("nine grid creates idle child using stable source token, not an auto generation", () => {
|
||||
const child = createNineGridNode({ id: "source", type: CanvasNodeType.Image, title: "图", position: { x: 0, y: 0 }, width: 320, height: 180, metadata: { content: "/image.png" } }, "child", 79, "九宫格", "nine_grid", "Grid3x3");
|
||||
expect(child.metadata?.prompt).toContain("@[node:source]");
|
||||
|
||||
Reference in new issue
Block a user