fix(*): 素材库与画布 - 修复批量删除刷新、选择布局及工具引用

This commit is contained in:
ddCat
2026-09-21 20:14:05 +08:00
parent 4e0b0a9256
commit 0146a232a7
27 changed files with 852 additions and 109 deletions

View File

@@ -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>

View File

@@ -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}

View File

@@ -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,

View File

@@ -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>
</>
);

View File

@@ -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);

View File

@@ -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");
}

View File

@@ -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[]) {

View File

@@ -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) }),

View File

@@ -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); }