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

This commit is contained in:
ddCat
2026-09-01 18:50:30 +08:00
21 changed files with 1766 additions and 342 deletions

View File

@@ -1,7 +1,7 @@
import { AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, Image as ImageIcon, Link2, MoreHorizontal, PencilLine, Play, Plus, Search, Trash2, Upload, type LucideIcon } from "lucide-react";
import { AlertTriangle, AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, Image as ImageIcon, Link2, MoreHorizontal, PencilLine, Play, Plus, RotateCcw, Search, Trash2, Upload, type LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { App, Button, Drawer, Dropdown, Form, Input, Modal, Progress, Select, Space, Tag, Typography } from "antd";
import { App, Button, Drawer, Dropdown, Form, Input, Modal, Popconfirm, Progress, Select, Space, Tag, Typography } from "antd";
import type { MenuProps } from "antd";
import { useNavigate } from "react-router";
@@ -10,6 +10,7 @@ import { WorkspaceState } from "@/components/layout/workspace-state";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
import { saveAs } from "file-saver";
import { cn } from "@/lib/utils";
import { useCopyText } from "@/hooks/use-copy-text";
import { ASSET_CATEGORY_OPTIONS, assetCategoryLabel } from "@/lib/asset-category";
@@ -17,11 +18,11 @@ import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } f
import { formatBytes, readFileAsDataUrl, readImageMeta } from "@/lib/image-utils";
import { uploadImage } from "@/services/image-storage";
import { uploadMediaFile } from "@/services/file-storage";
import { useAssetStore, type Asset, type AssetCategory, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
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 } from "@/services/user-data-sync";
import { deleteAssetWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { useUserStore } from "@/stores/use-user-store";
type LibraryAsset = Exclude<Asset, { kind: "entity" }>;
@@ -47,10 +48,7 @@ const kindOptions = [
{ label: "3D 模型", value: "model" },
];
const categoryOptions = [
{ label: "全部分类", value: "all" },
...ASSET_CATEGORY_OPTIONS,
];
const categoryOptions = [{ label: "全部分类", value: "all" }, ...ASSET_CATEGORY_OPTIONS];
const assetKindIcons: Record<LibraryAsset["kind"], LucideIcon> = {
text: FileText,
@@ -74,6 +72,8 @@ export default function AssetsPage() {
const addAsset = useAssetStore((state) => state.addAsset);
const updateAsset = useAssetStore((state) => state.updateAsset);
const retentionDays = useUserStore((state) => state.runtimeLimits.recycleBinRetentionDays ?? 30);
const [viewMode, setViewMode] = useState<"library" | "trash">("library");
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState<AssetKind | "all">("all");
const [categoryFilter, setCategoryFilter] = useState<AssetCategory | "all">("all");
@@ -83,8 +83,10 @@ export default function AssetsPage() {
const [isAssetOpen, setIsAssetOpen] = useState(false);
const [previewAsset, setPreviewAsset] = useState<LibraryAsset | null>(null);
const [deletingAsset, setDeletingAsset] = useState<LibraryAsset | null>(null);
const [archivingAsset, setArchivingAsset] = useState<LibraryAsset | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
const [batchArchiveOpen, setBatchArchiveOpen] = useState(false);
const [formKind, setFormKind] = useState<AssetKind>("text");
const [imageDraft, setImageDraft] = useState<ImageDraft>(null);
@@ -95,11 +97,15 @@ export default function AssetsPage() {
const title = Form.useWatch("title", form) || "";
const tags = Form.useWatch("tags", form) || [];
const content = Form.useWatch("content", form) || "";
const validAssets = useMemo(() => assets.filter((asset): asset is LibraryAsset => asset.kind !== "entity"), [assets]);
const allLibraryAssets = useMemo(() => assets.filter((asset): asset is LibraryAsset => asset.kind !== "entity"), [assets]);
const activeAssets = useMemo(() => allLibraryAssets.filter((asset) => asset.status !== "archived"), [allLibraryAssets]);
const trashAssets = useMemo(() => allLibraryAssets.filter((asset) => asset.status === "archived"), [allLibraryAssets]);
const validAssets = viewMode === "trash" ? trashAssets : activeAssets;
const selectedAssets = useMemo(() => validAssets.filter((asset) => selectedIds.includes(asset.id)), [selectedIds, validAssets]);
const kindCounts = useMemo(() => new Map(kindOptions.map((option) => [option.value, option.value === "all" ? validAssets.length : validAssets.filter((asset) => asset.kind === option.value).length])), [validAssets]);
const categoryCounts = useMemo(() => new Map(categoryOptions.map((option) => [option.value, option.value === "all" ? validAssets.length : validAssets.filter((asset) => (asset.category || "other") === option.value).length])), [validAssets]);
const canCreateAsset = !keyword.trim() && kindFilter === "all" && categoryFilter === "all";
const kindCounts = useMemo(() => new Map(kindOptions.map((option) => [option.value, option.value === "all" ? activeAssets.length : activeAssets.filter((asset) => asset.kind === option.value).length])), [activeAssets]);
const categoryCounts = useMemo(() => new Map(categoryOptions.map((option) => [option.value, option.value === "all" ? activeAssets.length : activeAssets.filter((asset) => (asset.category || "other") === option.value).length])), [activeAssets]);
const canCreateAsset = viewMode === "library" && !keyword.trim() && kindFilter === "all" && categoryFilter === "all";
const filteredAssets = useMemo(() => {
const query = keyword.trim().toLowerCase();
@@ -184,7 +190,7 @@ export default function AssetsPage() {
const base = {
title: values.title.trim(),
category: values.category,
status: editingAsset?.status || "confirmed" as const,
status: editingAsset?.status || ("confirmed" as const),
primaryVersionId: editingAsset?.primaryVersionId,
coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageData ? imageData.dataUrl : ""),
tags: values.tags || [],
@@ -234,7 +240,15 @@ export default function AssetsPage() {
if (!file || !/\.(glb|gltf)$/i.test(file.name)) return;
const uploaded = await uploadMediaFile(file, "model");
void queryClient.invalidateQueries({ queryKey: assetStorageUsageQueryKey });
addAsset({ kind: "model", title: file.name.replace(/\.(glb|gltf)$/i, ""), coverUrl: "", tags: ["3D模型"], source: "手动上传", data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: file.name }, metadata: { source: "manual" } });
addAsset({
kind: "model",
title: file.name.replace(/\.(glb|gltf)$/i, ""),
coverUrl: "",
tags: ["3D模型"],
source: "手动上传",
data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: file.name },
metadata: { source: "manual" },
});
message.success("3D 模型已保存");
};
@@ -277,11 +291,79 @@ export default function AssetsPage() {
}
};
const restoreAsset = async (asset: LibraryAsset) => {
updateAsset(asset.id, { status: "confirmed" });
await flushAssetStorePersistence();
try {
await saveRemoteUserDataNow();
message.success(`已还原素材「${asset.title}」`);
} catch {
message.warning("已在本地还原,稍后自动同步至云端");
}
};
const batchRestore = async () => {
if (!selectedIds.length) return;
for (const id of selectedIds) {
updateAsset(id, { status: "confirmed" });
}
const count = selectedIds.length;
setSelectedIds([]);
await flushAssetStorePersistence();
try {
await saveRemoteUserDataNow();
message.success(`已还原 ${count} 个素材`);
} catch {
message.warning("已在本地还原,稍后自动同步至云端");
}
};
const archiveAsset = async (asset: LibraryAsset) => {
updateAsset(asset.id, { status: "archived" });
await flushAssetStorePersistence();
try {
await saveRemoteUserDataNow();
message.success(`已将「${asset.title}」移入回收站`);
} catch {
message.warning("已移入回收站,稍后自动同步至云端");
}
};
const batchArchive = async () => {
if (!selectedIds.length) return;
for (const id of selectedIds) {
updateAsset(id, { status: "archived" });
}
const count = selectedIds.length;
setSelectedIds([]);
await flushAssetStorePersistence();
try {
await saveRemoteUserDataNow();
message.success(`已将 ${count} 个素材移入回收站`);
} catch {
message.warning("已移入回收站,稍后自动同步至云端");
}
};
const emptyTrash = async () => {
const count = trashAssets.length;
if (!count) return;
try {
for (const asset of trashAssets) {
await deleteAssetWithRemoteSync(asset.id);
}
setSelectedIds([]);
message.success(`已彻底清空回收站 ${count} 个素材`);
} catch (error) {
message.error(error instanceof Error ? error.message : "清空回收站失败");
}
};
const confirmDelete = async () => {
if (!deletingAsset) return;
try {
await deleteAssetWithRemoteSync(deletingAsset.id);
message.success("素材已删除");
message.success("素材已彻底删除");
setDeletingAsset(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "素材删除失败");
@@ -297,7 +379,7 @@ export default function AssetsPage() {
if (!selectedAssets.length) return;
try {
for (const asset of selectedAssets) await deleteAssetWithRemoteSync(asset.id);
message.success(`已删除 ${selectedAssets.length} 个素材`);
message.success(`已彻底删除 ${selectedAssets.length} 个素材`);
setSelectedIds([]);
setBatchDeleteOpen(false);
} catch (error) {
@@ -305,71 +387,244 @@ export default function AssetsPage() {
}
};
return (
<>
<WorkspacePage grid className="library-page assets-library-page canvas-library-page">
<div className="studio-band">
<PageHeader
title="素材库"
description="管理文本、图片、视频、音频和 3D 模型素材。"
meta={<span className="app-projects-header-meta assets-header-meta">{validAssets.length} 个素材</span>}
actions={(
<div className="assets-header-actions">
<div className="assets-header-action-buttons">
<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={openCreate}>新增素材</Button>
<Button icon={<FolderOpen className="size-3.5" />} onClick={() => navigate("/plugins/eagle")}>Eagle 素材库</Button>
<Button title="导出全部素材" aria-label="导出全部素材" icon={<Download className="size-4" />} onClick={() => void exportAllAssets()} />
<Dropdown trigger={["click"]} menu={{ items: [{ key: "package", icon: <FileUp className="size-4" />, label: "导入素材包", onClick: () => assetInputRef.current?.click() }, { key: "model", icon: <Upload className="size-4" />, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() }] }}>
<Button title="导入素材" aria-label="导入素材" icon={<FileUp className="size-4" />} />
</Dropdown>
<div className="studio-band">
<PageHeader
title={viewMode === "trash" ? "素材库 / 回收站" : "素材库"}
description={viewMode === "trash" ? "已删除画布或手动归档的临时素材,可随时还原或彻底清理。" : "管理文本、图片、视频、音频和 3D 模型素材。"}
meta={<span className="app-projects-header-meta assets-header-meta">{validAssets.length} 个素材</span>}
actions={
<div className="assets-header-actions">
<div className="assets-header-action-buttons">
{viewMode === "trash" ? (
<>
{trashAssets.length > 0 ? (
<Popconfirm
title="确定清空回收站吗?"
description="清空后所有回收站素材及其文件将被彻底永久删除,不可恢复。"
onConfirm={() => void emptyTrash()}
okText="清空"
okButtonProps={{ danger: true }}
cancelText="取消"
>
<Button danger icon={<Trash2 className="size-3.5" />}>
清空回收站
</Button>
</Popconfirm>
) : null}
<Button
icon={<RotateCcw className="size-3.5" />}
onClick={() => {
setViewMode("library");
setPage(1);
setSelectedIds([]);
}}
>
返回素材库
</Button>
</>
) : (
<>
<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={openCreate}>
新增素材
</Button>
<Button icon={<FolderOpen className="size-3.5" />} onClick={() => navigate("/plugins/eagle")}>
Eagle 素材库
</Button>
<Button title="导出全部素材" aria-label="导出全部素材" icon={<Download className="size-4" />} onClick={() => void exportAllAssets()} />
<Dropdown
trigger={["click"]}
menu={{
items: [
{ key: "package", icon: <FileUp className="size-4" />, label: "导入素材包", onClick: () => assetInputRef.current?.click() },
{ key: "model", icon: <Upload className="size-4" />, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() },
],
}}
>
<Button title="导入素材" aria-label="导入素材" icon={<FileUp className="size-4" />} />
</Dropdown>
</>
)}
</div>
<AssetStorageUsage />
</div>
<AssetStorageUsage />
</div>
)}
/>
<ListToolbar className="library-toolbar" active={Boolean(keyword || kindFilter !== "all" || categoryFilter !== "all")} onReset={() => { setKeyword(""); setKindFilter("all"); setCategoryFilter("all"); setPage(1); }}>
<Input allowClear className="w-full sm:w-80" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索标题、内容、标签或来源" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} />
</ListToolbar>
</div>
<div className="canvas-library-frame assets-library-frame">
<div className="grid min-h-0 gap-4 lg:grid-cols-[176px_minmax(0,1fr)]">
<aside className="thin-scrollbar flex gap-2 overflow-x-auto py-3 lg:sticky lg:top-0 lg:block lg:max-h-[calc(100vh-150px)] lg:overflow-y-auto lg:pr-3">
<AssetFilterGroup title="素材类型" options={kindOptions} value={kindFilter} counts={kindCounts} onChange={(value) => { setKindFilter(value as AssetKind | "all"); setPage(1); }} />
<AssetFilterGroup title="业务分类" options={categoryOptions} value={categoryFilter} counts={categoryCounts} onChange={(value) => { setCategoryFilter(value as AssetCategory | "all"); setPage(1); }} className="lg:mt-5" />
</aside>
<section className="min-w-0">
{selectedAssets.length ? (
<AssetsBatchBar count={selectedAssets.length} allSelected={allFilteredSelected} onSelectAll={() => setSelectedIds((current) => Array.from(new Set([...current, ...filteredAssetIds])))} onClear={() => setSelectedIds([])} onExport={() => void exportSelectedAssets()} onDelete={() => setBatchDeleteOpen(true)} />
) : null}
{validAssets.length === 0 ? (
<AssetsEmptyState onNew={openCreate} onImport={() => assetInputRef.current?.click()} onGoCanvas={() => navigate("/canvas")} />
) : (
<>
{filteredAssets.length === 0 ? (
<WorkspaceState icon="assets" compact title="没有匹配的素材" description="调整关键词或左侧分类后再试。" />
) : (
<CollectionGrid className="library-grid assets-library-grid">
{canCreateAsset ? <button type="button" className="library-create-card" onClick={openCreate}>
<span className="library-create-cover"><Plus className="size-8" /></span>
<span className="library-create-title">新增素材</span>
<span className="library-create-meta">文本、图片、音视频或模型</span>
</button> : null}
{visibleAssets.map((asset) => (
<AssetCard key={asset.id} asset={asset} selected={selectedIds.includes(asset.id)} onSelect={(selected) => setSelectedIds((current) => selected ? [...new Set([...current, asset.id])] : current.filter((id) => id !== asset.id))} onOpen={() => setPreviewAsset(asset)} onEdit={() => openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} />
))}
</CollectionGrid>
)}
<PaginationBar current={page} pageSize={pageSize} total={filteredAssets.length} pageSizeOptions={[35, 70, 105]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
</>
)}
</section>
}
/>
<ListToolbar
className="library-toolbar"
active={Boolean(keyword || kindFilter !== "all" || categoryFilter !== "all")}
onReset={() => {
setKeyword("");
setKindFilter("all");
setCategoryFilter("all");
setPage(1);
}}
>
<Input
allowClear
className="w-full sm:w-80"
prefix={<Search className="size-4 text-foreground/40" />}
value={keyword}
placeholder="搜索标题、内容、标签或来源"
onChange={(event) => {
setPage(1);
setKeyword(event.target.value);
}}
/>
</ListToolbar>
</div>
<div className="canvas-library-frame assets-library-frame">
<div className="grid min-h-0 gap-4 lg:grid-cols-[176px_minmax(0,1fr)]">
<aside className="thin-scrollbar flex gap-2 overflow-x-auto py-3 lg:sticky lg:top-0 lg:block lg:max-h-[calc(100vh-150px)] lg:overflow-y-auto lg:pr-3">
<AssetFilterGroup
title="素材类型"
options={kindOptions}
value={viewMode === "library" ? kindFilter : ""}
counts={kindCounts}
onChange={(value) => {
setViewMode("library");
setKindFilter(value as AssetKind | "all");
setPage(1);
}}
/>
<AssetFilterGroup
title="业务分类"
options={categoryOptions}
value={viewMode === "library" ? categoryFilter : ""}
counts={categoryCounts}
onChange={(value) => {
setViewMode("library");
setCategoryFilter(value as AssetCategory | "all");
setPage(1);
}}
className="lg:mt-5"
/>
<div className="mt-6 border-t border-border/40 pt-3">
<div className="mb-1.5 px-1 text-[var(--fs-tiny)] font-semibold uppercase tracking-[0.08em] text-foreground/38">垃圾箱与归档</div>
<button
type="button"
aria-pressed={viewMode === "trash"}
className={cn(
"assets-filter-item w-full transition-colors",
viewMode === "trash" ? "is-active !bg-amber-500/15 !text-amber-600 dark:!text-amber-400 font-semibold shadow-sm" : "text-foreground/65 hover:text-foreground",
)}
onClick={() => {
if (viewMode === "trash") {
setViewMode("library");
} else {
setViewMode("trash");
setKindFilter("all");
setCategoryFilter("all");
}
setPage(1);
setSelectedIds([]);
}}
>
<span className="assets-filter-item-label flex items-center gap-1.5">
<Trash2 className="size-3.5" />
<span>回收站</span>
</span>
<span className="assets-filter-count">{trashAssets.length}</span>
</button>
</div>
</aside>
<section className="min-w-0">
{viewMode === "trash" ? (
<div className="mb-4 flex items-center justify-between gap-3 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-xs text-amber-600 dark:text-amber-300">
<div className="flex items-center gap-2">
<AlertTriangle className="size-4 shrink-0 text-amber-500" />
<span>{retentionDays > 0 ? `回收站内的素材将在 ${retentionDays} 天后自动彻底清除。您可以随时还原素材,或手动彻底删除释放空间。` : "回收站内的素材当前设置为永久保留,您可以随时还原素材或手动彻底清除。"}</span>
</div>
</div>
) : null}
{selectedAssets.length ? (
<AssetsBatchBar
count={selectedAssets.length}
isTrash={viewMode === "trash"}
allSelected={allFilteredSelected}
onSelectAll={() => setSelectedIds((current) => Array.from(new Set([...current, ...filteredAssetIds])))}
onClear={() => setSelectedIds([])}
onExport={() => void exportSelectedAssets()}
onRestore={() => void batchRestore()}
onArchive={() => setBatchArchiveOpen(true)}
onDelete={() => setBatchDeleteOpen(true)}
/>
) : null}
{validAssets.length === 0 ? (
viewMode === "trash" ? (
<WorkspaceState icon="assets" compact title="回收站是空的" description="删除画布或手动移入回收站的素材会暂存到这里,可在需要时随时还原。" />
) : (
<AssetsEmptyState onNew={openCreate} onImport={() => assetInputRef.current?.click()} onGoCanvas={() => navigate("/canvas")} />
)
) : (
<>
{filteredAssets.length === 0 ? (
<WorkspaceState icon="assets" compact title="没有匹配的素材" description="调整关键词或左侧分类后再试。" />
) : (
<CollectionGrid className="library-grid assets-library-grid">
{canCreateAsset ? (
<button type="button" className="library-create-card" onClick={openCreate}>
<span className="library-create-cover">
<Plus className="size-8" />
</span>
<span className="library-create-title">新增素材</span>
<span className="library-create-meta">文本、图片、音视频或模型</span>
</button>
) : null}
{visibleAssets.map((asset) => (
<AssetCard
key={asset.id}
asset={asset}
selected={selectedIds.includes(asset.id)}
isTrash={viewMode === "trash"}
retentionDays={retentionDays}
onSelect={(selected) => setSelectedIds((current) => (selected ? [...new Set([...current, asset.id])] : current.filter((id) => id !== asset.id)))}
onOpen={() => setPreviewAsset(asset)}
onEdit={() => openEdit(asset)}
onCopy={copyAssetText}
onDownload={downloadImage}
onRestore={() => void restoreAsset(asset)}
onArchive={() => setArchivingAsset(asset)}
onDelete={() => setDeletingAsset(asset)}
/>
))}
</CollectionGrid>
)}
<PaginationBar
current={page}
pageSize={pageSize}
total={filteredAssets.length}
pageSizeOptions={[35, 70, 105]}
onChange={(nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage);
setPageSize(nextPageSize);
}}
/>
</>
)}
</section>
</div>
</div>
</div>
</WorkspacePage>
<Modal className="workspace-modal workspace-modal-wide library-modal" title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} onCancel={() => { if (!imageUploading) setIsAssetOpen(false); }} onOk={() => void saveAsset()} okText={imageUploading ? "正在上传" : "保存"} cancelText="取消" confirmLoading={imageUploading} cancelButtonProps={{ disabled: imageUploading }} closable={!imageUploading} destroyOnHidden>
<Modal
className="workspace-modal workspace-modal-wide library-modal"
title={editingAsset ? "编辑素材" : "新增素材"}
open={isAssetOpen}
onCancel={() => {
if (!imageUploading) setIsAssetOpen(false);
}}
onOk={() => void saveAsset()}
okText={imageUploading ? "正在上传" : "保存"}
cancelText="取消"
confirmLoading={imageUploading}
cancelButtonProps={{ disabled: imageUploading }}
closable={!imageUploading}
destroyOnHidden
>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", category: "other", tags: [] }}>
<Form.Item name="kind" label="类型">
@@ -385,7 +640,7 @@ export default function AssetsPage() {
<Select options={categoryOptions.slice(1)} />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
<Input placeholder="给素材起一个容易检索的名字" />
<Input placeholder="给素材起一个容易检索的名字" />
</Form.Item>
<Form.Item name="coverUrl" label="封面 URL">
<Space.Compact className="w-full">
@@ -416,7 +671,11 @@ export default function AssetsPage() {
<Button disabled={imageUploading} icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}>
{imageUploading ? "正在上传图片" : "选择图片文件"}
</Button>
{imageFile ? <Tag color="gold" className="ml-3">待保存上传</Tag> : null}
{imageFile ? (
<Tag color="gold" className="ml-3">
待保存上传
</Tag>
) : null}
{imageDraft ? (
<Typography.Text type="secondary" className="ml-3 text-xs" title={resourceStorageTitle(imageDraft.storageKey)}>
{imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)} · {resourceStorageLabel(imageDraft.storageKey)}
@@ -431,7 +690,9 @@ export default function AssetsPage() {
)}
</Form>
<div className="lg:pl-4">
<Typography.Text strong className="text-xs">预览</Typography.Text>
<Typography.Text strong className="text-xs">
预览
</Typography.Text>
<div className="mt-2 overflow-hidden rounded-md bg-stone-100 dark:bg-stone-900">
{coverUrl || imageDraft?.dataUrl ? (
<div className={`asset-preview-uploading ${imageUploading ? "is-uploading" : ""}`}>
@@ -493,36 +754,153 @@ export default function AssetsPage() {
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
<input ref={modelInputRef} type="file" accept=".glb,.gltf,model/gltf-binary,model/gltf+json" className="hidden" onChange={(event) => { void readModelFile(event.target.files?.[0]); event.currentTarget.value = ""; }} />
<input
ref={modelInputRef}
type="file"
accept=".glb,.gltf,model/gltf-binary,model/gltf+json"
className="hidden"
onChange={(event) => {
void readModelFile(event.target.files?.[0]);
event.currentTarget.value = "";
}}
/>
<Modal className="library-modal library-confirm-modal" title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={() => void confirmDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
确定删除「{deletingAsset?.title}」吗?未被其他内容引用的服务器本地或对象存储文件也会同步删除;若仍被画布、任务或其他素材占用,本次删除将被阻止。
<Modal
className="library-modal library-confirm-modal"
title="移入回收站"
open={Boolean(archivingAsset)}
onCancel={() => setArchivingAsset(null)}
onOk={() => {
if (archivingAsset) {
void archiveAsset(archivingAsset);
setArchivingAsset(null);
}
}}
okText="移入回收站"
cancelText="取消"
>
确定将「{archivingAsset?.title}」移入回收站吗?移入后不会出现在正常素材库中,可在回收站随时还原。
</Modal>
<Modal className="library-modal library-confirm-modal" title="批量删除素材" open={batchDeleteOpen} onCancel={() => setBatchDeleteOpen(false)} onOk={() => void confirmBatchDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
确定删除已选择的 {selectedAssets.length} 个素材吗?未被复用的服务器文件会同步删除;仍被画布、任务或其他素材占用的素材会保留并提示具体来源。
<Modal
className="library-modal library-confirm-modal"
title="批量移入回收站"
open={batchArchiveOpen}
onCancel={() => setBatchArchiveOpen(false)}
onOk={() => {
void batchArchive();
setBatchArchiveOpen(false);
}}
okText="移入回收站"
cancelText="取消"
>
确定将已选择的 {selectedAssets.length} 个素材移入回收站吗?移入后可随时在回收站批量还原。
</Modal>
<Modal
className="library-modal library-confirm-modal"
title="彻底删除素材"
open={Boolean(deletingAsset)}
onCancel={() => setDeletingAsset(null)}
onOk={() => void confirmDelete()}
okText="彻底删除"
okButtonProps={{ danger: true }}
cancelText="取消"
>
确定彻底删除「{deletingAsset?.title}」吗?未被其他内容引用的服务器本地或对象存储文件也会同步删除,操作不可恢复。
</Modal>
<Modal
className="library-modal library-confirm-modal"
title="批量彻底删除素材"
open={batchDeleteOpen}
onCancel={() => setBatchDeleteOpen(false)}
onOk={() => void confirmBatchDelete()}
okText="彻底删除"
okButtonProps={{ danger: true }}
cancelText="取消"
>
确定彻底删除已选择的 {selectedAssets.length} 个素材吗?未被复用的服务器文件会同步删除,操作不可恢复。
</Modal>
</>
);
}
function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: LibraryAsset; selected: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; onEdit: () => void; onCopy: (asset: LibraryAsset) => void; onDownload: (asset: LibraryAsset) => void; onDelete: () => void }) {
function formatExpirationHint(updatedAt: string, retentionDays: number) {
if (!retentionDays || retentionDays <= 0) return "永久保留";
const updatedTime = new Date(updatedAt).getTime();
if (!Number.isFinite(updatedTime)) return `保留 ${retentionDays} 天`;
const expireTime = updatedTime + retentionDays * 24 * 60 * 60 * 1000;
const remainingMs = expireTime - Date.now();
const remainingDays = Math.ceil(remainingMs / (24 * 60 * 60 * 1000));
if (remainingDays <= 0) return "即将彻底清除";
if (remainingDays === 1) return "剩余 1 天过期";
return `剩余 ${remainingDays} 天过期`;
}
function formatExpirationDate(updatedAt: string, retentionDays: number) {
if (!retentionDays || retentionDays <= 0) return "永久保留";
const updatedTime = new Date(updatedAt).getTime();
if (!Number.isFinite(updatedTime)) return "";
const expireDate = new Date(updatedTime + retentionDays * 24 * 60 * 60 * 1000);
return `预计于 ${expireDate.getFullYear()}-${String(expireDate.getMonth() + 1).padStart(2, "0")}-${String(expireDate.getDate()).padStart(2, "0")} 彻底清除`;
}
function AssetCard({
asset,
selected,
isTrash = false,
retentionDays = 30,
onSelect,
onOpen,
onEdit,
onCopy,
onDownload,
onRestore,
onArchive,
onDelete,
}: {
asset: LibraryAsset;
selected: boolean;
isTrash?: boolean;
retentionDays?: number;
onSelect: (selected: boolean) => void;
onOpen: () => void;
onEdit: () => void;
onCopy: (asset: LibraryAsset) => void;
onDownload: (asset: LibraryAsset) => void;
onRestore?: () => void;
onArchive?: () => void;
onDelete: () => void;
}) {
const summary = assetSummary(asset);
const menuItems: MenuProps["items"] = [
...(asset.kind === "text" || asset.kind === "image" ? [{ key: "edit", icon: <PencilLine className="size-3.5" />, label: "编辑", onClick: onEdit }] : []),
...(asset.kind === "text" ? [{ key: "copy", icon: <Copy className="size-3.5" />, label: "复制文本", onClick: () => void onCopy(asset) }] : []),
...(asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? [{ key: "download", icon: <Download className="size-3.5" />, label: "下载", onClick: () => onDownload(asset) }] : []),
{ type: "divider" as const },
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除", onClick: onDelete },
];
const menuItems: MenuProps["items"] = isTrash
? [{ key: "restore", icon: <RotateCcw className="size-3.5" />, label: "还原到素材库", onClick: onRestore }, { type: "divider" as const }, { key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "彻底删除", onClick: onDelete }]
: [
...(asset.kind === "text" || asset.kind === "image" ? [{ key: "edit", icon: <PencilLine className="size-3.5" />, label: "编辑", onClick: onEdit }] : []),
...(asset.kind === "text" ? [{ key: "copy", icon: <Copy className="size-3.5" />, label: "复制文本", onClick: () => void onCopy(asset) }] : []),
...(asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? [{ key: "download", icon: <Download className="size-3.5" />, label: "下载", onClick: () => onDownload(asset) }] : []),
{ type: "divider" as const },
{ key: "archive", icon: <Trash2 className="size-3.5 text-amber-500" />, label: "移入回收站", onClick: onArchive },
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "彻底删除", onClick: onDelete },
];
return (
<AssetLibraryCard selected={selected}>
<AssetCover asset={asset} selected={selected} onSelect={onSelect} onOpen={onOpen} menuItems={menuItems} />
<AssetCover asset={asset} selected={selected} isTrash={isTrash} onSelect={onSelect} onOpen={onOpen} menuItems={menuItems} />
<button type="button" className="block w-full px-2.5 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]" onClick={onOpen}>
<div className="flex min-w-0 items-center justify-between gap-2">
<h2 className="truncate text-[var(--fs-body)] font-semibold text-foreground" title={asset.title}>{asset.title}</h2>
<h2 className="truncate text-[var(--fs-body)] font-semibold text-foreground" title={asset.title}>
{asset.title}
</h2>
<span className="shrink-0 text-[var(--fs-tiny)] tabular-nums text-foreground/38">{formatAssetTime(asset.updatedAt)}</span>
</div>
<div className="mt-1 truncate text-[var(--fs-label)] text-foreground/52" title={summary}>{summary}</div>
{isTrash ? (
<div className="mt-1 flex items-center gap-1 text-[var(--fs-tiny)] font-medium text-amber-600 dark:text-amber-400" title={formatExpirationDate(asset.updatedAt, retentionDays)}>
<AlertTriangle className="size-3 shrink-0" />
<span>{formatExpirationHint(asset.updatedAt, retentionDays)}</span>
</div>
) : (
<div className="mt-1 truncate text-[var(--fs-label)] text-foreground/52" title={summary}>
{summary}
</div>
)}
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/38">
<span className="truncate">{asset.source || "未标注来源"}</span>
<span aria-hidden="true">·</span>
@@ -533,7 +911,7 @@ function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownlo
);
}
function AssetCover({ asset, selected, onSelect, onOpen, menuItems }: { asset: LibraryAsset; selected: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; menuItems: MenuProps["items"] }) {
function AssetCover({ asset, selected, isTrash = false, onSelect, onOpen, menuItems }: { asset: LibraryAsset; selected: boolean; isTrash?: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; menuItems: MenuProps["items"] }) {
const KindIcon = assetKindIcons[asset.kind];
const clock = asset.kind === "video" || asset.kind === "audio" ? formatAssetClock(asset.data.durationMs) : null;
const showPlay = asset.kind === "video";
@@ -548,21 +926,34 @@ function AssetCover({ asset, selected, onSelect, onOpen, menuItems }: { asset: L
) : asset.kind === "model" ? (
<ModelCover asset={asset} />
) : (
<AssetMediaPreview asset={asset} alt={asset.title} className="assets-cover-media" fallback={<div className="assets-cover-fallback"><KindIcon className="size-7" /></div>} />
<AssetMediaPreview
asset={asset}
alt={asset.title}
className="assets-cover-media"
fallback={
<div className="assets-cover-fallback">
<KindIcon className="size-7" />
</div>
}
/>
)}
<span className="assets-cover-vignette" aria-hidden="true" />
{showPlay ? <span className="assets-cover-play"><Play className="size-4" /></span> : null}
{showPlay ? (
<span className="assets-cover-play">
<Play className="size-4" />
</span>
) : null}
</button>
<span className="assets-cover-badges">
<span className="assets-cover-badge is-kind"><KindIcon />{assetKindLabel(asset.kind)}</span>
<span className="assets-cover-badge is-category">{assetCategoryLabel(asset.category)}</span>
<span className="assets-cover-badge is-kind">
<KindIcon />
{assetKindLabel(asset.kind)}
</span>
{isTrash ? <span className="assets-cover-badge is-category !bg-amber-500/85 !text-white">回收站</span> : <span className="assets-cover-badge is-category">{assetCategoryLabel(asset.category)}</span>}
</span>
{clock ? <span className="assets-cover-clock">{clock}</span> : null}
<input type="checkbox" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelect(event.target.checked)} className="assets-select-check" aria-label={`选择 ${asset.title}`} />
<Dropdown
trigger={["click"]}
menu={{ items: menuItems }}
>
<Dropdown trigger={["click"]} menu={{ items: menuItems }}>
<button type="button" className="assets-cover-more" aria-label="更多素材操作" title="更多操作">
<MoreHorizontal className="size-4" />
</button>
@@ -575,7 +966,9 @@ function AudioWaveCover({ asset }: { asset: LibraryAsset & { kind: "audio" } })
const bars = audioWaveBars(asset.id);
return (
<div className="assets-cover-wave" aria-hidden="true">
{bars.map((height, index) => <span key={index} style={{ height: `${height}%` }} />)}
{bars.map((height, index) => (
<span key={index} style={{ height: `${height}%` }} />
))}
<AudioLines className="assets-cover-wave-glyph" />
</div>
);
@@ -598,15 +991,61 @@ function ModelCover({ asset }: { asset: LibraryAsset & { kind: "model" } }) {
);
}
function AssetsBatchBar({ count, allSelected, onSelectAll, onClear, onExport, onDelete }: { count: number; allSelected: boolean; onSelectAll: () => void; onClear: () => void; onExport: () => void; onDelete: () => void }) {
function AssetsBatchBar({
count,
isTrash = false,
allSelected,
onSelectAll,
onClear,
onExport,
onRestore,
onArchive,
onDelete,
}: {
count: number;
isTrash?: boolean;
allSelected: boolean;
onSelectAll: () => void;
onClear: () => void;
onExport: () => void;
onRestore?: () => void;
onArchive?: () => void;
onDelete: () => void;
}) {
return (
<div className="assets-batch-bar" role="toolbar" aria-label="批量操作">
<span className="assets-batch-count">已选择 <strong>{count}</strong> 个素材</span>
<span className="assets-batch-count">
已选择 <strong>{count}</strong> 个素材
</span>
<div className="assets-batch-actions">
<Button size="small" icon={<CheckCheck className="size-3.5" />} disabled={allSelected} onClick={onSelectAll}>全选</Button>
<Button size="small" onClick={onClear}>取消选择</Button>
<Button size="small" icon={<Download className="size-3.5" />} onClick={onExport}>导出</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>删除</Button>
<Button size="small" icon={<CheckCheck className="size-3.5" />} disabled={allSelected} onClick={onSelectAll}>
全选
</Button>
<Button size="small" onClick={onClear}>
取消选择
</Button>
{isTrash ? (
<>
<Button size="small" type="primary" icon={<RotateCcw className="size-3.5" />} onClick={onRestore}>
还原已选
</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>
彻底删除已选
</Button>
</>
) : (
<>
<Button size="small" icon={<Download className="size-3.5" />} onClick={onExport}>
导出
</Button>
<Button size="small" icon={<Trash2 className="size-3.5 text-amber-500" />} onClick={onArchive}>
移入回收站
</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>
彻底删除
</Button>
</>
)}
</div>
</div>
);
@@ -628,21 +1067,29 @@ function AssetsEmptyState({ onNew, onImport, onGoCanvas }: { onNew: () => void;
<span>{frame.caption}</span>
</figure>
))}
<span className="assets-empty-banner-caption"><span>影策素材库</span>把每次创作的结果,留档成可复用的资产</span>
<span className="assets-empty-banner-caption">
<span>影策素材库</span>把每次创作的结果,留档成可复用的资产
</span>
</div>
<div className="assets-empty-cards">
<button type="button" className="assets-empty-card" onClick={onNew}>
<span className="assets-empty-card-icon"><Plus /></span>
<span className="assets-empty-card-icon">
<Plus />
</span>
<strong>新建素材</strong>
<span>录入提示词、说明文案,或上传图片资产。</span>
</button>
<button type="button" className="assets-empty-card" onClick={onImport}>
<span className="assets-empty-card-icon"><FileUp /></span>
<span className="assets-empty-card-icon">
<FileUp />
</span>
<strong>导入素材包</strong>
<span>从素材压缩包一键恢复旧资产,继续创作。</span>
</button>
<button type="button" className="assets-empty-card" onClick={onGoCanvas}>
<span className="assets-empty-card-icon"><Clapperboard /></span>
<span className="assets-empty-card-icon">
<Clapperboard />
</span>
<strong>去画布保存</strong>
<span>把画布上满意的镜头与画面留档进素材库。</span>
</button>
@@ -651,7 +1098,21 @@ function AssetsEmptyState({ onNew, onImport, onGoCanvas }: { onNew: () => void;
);
}
function AssetFilterGroup({ title, options, value, counts, onChange, className = "" }: { title: string; options: Array<{ label: string; value: string }>; value: string; counts: Map<string, number>; onChange: (value: string) => void; className?: string }) {
function AssetFilterGroup({
title,
options,
value,
counts,
onChange,
className = "",
}: {
title: string;
options: Array<{ label: string; value: string }>;
value: string;
counts: Map<string, number>;
onChange: (value: string) => void;
className?: string;
}) {
return (
<div className={className}>
<div className="mb-1.5 px-1 text-[var(--fs-tiny)] font-semibold uppercase tracking-[0.08em] text-foreground/38">{title}</div>
@@ -678,19 +1139,30 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAss
{asset ? (
<div className="space-y-4">
<div className="asset-archive-header">
<span className="asset-archive-header-icon"><KindIcon /></span>
<span className="asset-archive-header-icon">
<KindIcon />
</span>
<div className="min-w-0">
<h2 className="asset-archive-title">{asset.title}</h2>
<p className="asset-archive-subtitle">{assetCategoryLabel(asset.category)} · {formatAssetDateTime(asset.createdAt)} 创建</p>
<p className="asset-archive-subtitle">
{assetCategoryLabel(asset.category)} · {formatAssetDateTime(asset.createdAt)} 创建
</p>
</div>
</div>
<div className="asset-archive-preview">
{asset.kind === "text" ? (
<div className="asset-archive-preview-note">{asset.data.content}</div>
) : asset.kind === "audio" ? (
<div className="asset-archive-audio"><audio src={asset.data.url} controls /></div>
<div className="asset-archive-audio">
<audio src={asset.data.url} controls />
</div>
) : asset.kind === "model" ? (
<div className="asset-archive-preview-model"><Box /><span>{asset.data.fileName} · {formatBytes(asset.data.bytes)}</span></div>
<div className="asset-archive-preview-model">
<Box />
<span>
{asset.data.fileName} · {formatBytes(asset.data.bytes)}
</span>
</div>
) : asset.kind === "video" ? (
<video src={asset.data.url} controls className="asset-archive-preview-media" />
) : (
@@ -699,7 +1171,9 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAss
</div>
<div className="flex flex-wrap gap-1.5">
{(asset.tags || []).map((tag) => (
<Tag key={tag} className="m-0">{tag}</Tag>
<Tag key={tag} className="m-0">
{tag}
</Tag>
))}
<StorageTag asset={asset} />
</div>
@@ -707,11 +1181,17 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAss
{facts.map((fact) => (
<div key={fact.label} className="asset-archive-fact">
<span className="asset-archive-fact-label">{fact.label}</span>
<span className="asset-archive-fact-value" title={fact.value}>{fact.value}</span>
<span className="asset-archive-fact-value" title={fact.value}>
{fact.value}
</span>
</div>
))}
</div>
<div className="asset-archive-link"><Link2 /><span>所属项目</span><strong>{assetProjectLabel(asset)}</strong></div>
<div className="asset-archive-link">
<Link2 />
<span>所属项目</span>
<strong>{assetProjectLabel(asset)}</strong>
</div>
{asset.note ? (
<div className="asset-archive-section">
<span className="asset-archive-section-title">备注</span>
@@ -720,10 +1200,14 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAss
) : null}
<div className="asset-archive-actions">
{asset.kind === "text" ? (
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}>复制文本</Button>
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}>
复制文本
</Button>
) : null}
{asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? (
<Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}>{assetDownloadLabel(asset)}</Button>
<Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}>
{assetDownloadLabel(asset)}
</Button>
) : null}
</div>
</div>