import { AlertTriangle, AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, FolderPlus, Image as ImageIcon, Images, LayoutGrid, Link2, Maximize2, MoreHorizontal, PencilLine, Play, Plus, RotateCcw, Search, Trash2, Upload, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; 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"; import { CollectionGrid, ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page"; import { WorkspaceState } from "@/components/layout/workspace-state"; import { AssetMediaPreview } from "@/components/asset-media-preview"; import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card"; import { Switch } from "@/components/ui/base/switch"; import { saveAs } from "file-saver"; import { cn } from "@/lib/utils"; import { useCopyText } from "@/hooks/use-copy-text"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { ASSET_CATEGORY_OPTIONS, assetCategoryLabel } from "@/lib/asset-category"; import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status"; import { formatBytes, readFileAsDataUrl, readImageMeta } from "@/lib/image-utils"; import { uploadImage } from "@/services/image-storage"; 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 { 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"; import { useAppearanceStore } from "@/stores/use-appearance-store"; type LibraryAsset = Exclude; type AssetFormValues = { kind: AssetKind; category: AssetCategory; folderId?: string; title: string; coverUrl: string; tags: string[]; source?: string; note?: string; content?: string; arkAssetId?: string; portraitCertified?: boolean; }; type ImageDraft = ImageAsset["data"] | null; const kindOptions = [ { label: "全部", value: "all" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }, { label: "3D 模型", value: "model" }, ]; const categoryOptions = [{ label: "全部分类", value: "all" }, ...ASSET_CATEGORY_OPTIONS]; const ASSET_LIBRARY_QUERY_KEY = ["asset-library"] as const; const ASSET_FOLDER_QUERY_KEY = ["asset-folders"] as const; const ASSET_GRID_DENSITY_KEY = "infinite-canvas:asset-grid-density"; type AssetGridDensity = 6 | 8 | 10; type AssetFolderFilter = "all" | "uncategorized" | string; const assetKindIcons: Record = { text: FileText, image: ImageIcon, video: Clapperboard, audio: AudioLines, model: Box, }; export default function AssetsPage() { const { message } = App.useApp(); const navigate = useNavigate(); const queryClient = useQueryClient(); const copyText = useCopyText(); const [form] = Form.useForm(); const coverInputRef = useRef(null); const imageInputRef = useRef(null); const assetInputRef = useRef(null); const modelInputRef = useRef(null); const assets = useAssetStore((state) => state.assets); const addAsset = useAssetStore((state) => state.addAsset); const updateAsset = useAssetStore((state) => state.updateAsset); const userId = useUserStore((state) => state.user?.id || ""); const retentionDays = useUserStore((state) => state.runtimeLimits.recycleBinRetentionDays ?? 30); const [viewMode, setViewMode] = useState<"library" | "trash">("library"); const [keyword, setKeyword] = useState(""); const [kindFilter, setKindFilter] = useState("all"); const [categoryFilter, setCategoryFilter] = useState("all"); const [folderFilter, setFolderFilter] = useState("all"); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(40); const [gridDensity, setGridDensity] = useState(readAssetGridDensity); const [editingAsset, setEditingAsset] = useState(null); const [isAssetOpen, setIsAssetOpen] = useState(false); const [previewAsset, setPreviewAsset] = useState(null); const [deletingAsset, setDeletingAsset] = useState(null); const [archivingAsset, setArchivingAsset] = useState(null); const [selectedIds, setSelectedIds] = useState([]); const [batchDeleteOpen, setBatchDeleteOpen] = useState(false); const [batchArchiveOpen, setBatchArchiveOpen] = useState(false); const [batchUploadOpen, setBatchUploadOpen] = useState(false); const [folderEditor, setFolderEditor] = useState(null); const [folderName, setFolderName] = useState(""); const [folderSaving, setFolderSaving] = useState(false); const [formKind, setFormKind] = useState("text"); const [imageDraft, setImageDraft] = useState(null); const [imageFile, setImageFile] = useState(null); const [imageUploading, setImageUploading] = useState(false); const [imageUploadProgress, setImageUploadProgress] = useState<{ phase: "uploading" | "confirming"; percent?: number } | null>(null); const coverUrl = Form.useWatch("coverUrl", form) || ""; const title = Form.useWatch("title", form) || ""; const tags = Form.useWatch("tags", form) || []; const content = Form.useWatch("content", form) || ""; const debouncedKeyword = useDebouncedValue(keyword.trim(), 250); const foldersQuery = useQuery({ queryKey: ASSET_FOLDER_QUERY_KEY, queryFn: () => listAssetFolders(), enabled: Boolean(userId), }); const folders = foldersQuery.data?.folders || []; 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 filteredAssets = useMemo(() => { const query = keyword.trim().toLowerCase(); return validAssets.filter((asset) => { if (kindFilter !== "all" && asset.kind !== kindFilter) return false; if (categoryFilter !== "all" && (asset.category || "other") !== categoryFilter) return false; if (folderFilter === "uncategorized" && asset.folderId) return false; if (folderFilter !== "all" && folderFilter !== "uncategorized" && asset.folderId !== folderFilter) return false; if (!query) return true; return assetSearchText(asset).includes(query); }); }, [validAssets, keyword, kindFilter, categoryFilter, folderFilter]); const assetPageQuery = useQuery({ queryKey: [...ASSET_LIBRARY_QUERY_KEY, page, pageSize, viewMode, kindFilter, categoryFilter, folderFilter, debouncedKeyword], queryFn: ({ signal }) => loadAssetLibraryPage({ page, pageSize, status: viewMode === "trash" ? "archived" : "active", kind: kindFilter === "all" ? undefined : kindFilter, category: categoryFilter === "all" ? undefined : categoryFilter, folderId: folderFilter !== "all" && folderFilter !== "uncategorized" ? folderFilter : undefined, uncategorized: folderFilter === "uncategorized", query: debouncedKeyword || undefined, signal, }), enabled: Boolean(userId), placeholderData: keepPreviousData, }); const localVisibleAssets = useMemo(() => { const start = (page - 1) * pageSize; return filteredAssets.slice(start, start + pageSize); }, [filteredAssets, page, pageSize]); const visibleAssets = useMemo( () => (assetPageQuery.data?.assets || localVisibleAssets).filter((asset): asset is LibraryAsset => asset.kind !== "entity"), [assetPageQuery.data?.assets, localVisibleAssets], ); const visibleAssetIds = useMemo(() => visibleAssets.map((asset) => asset.id), [visibleAssets]); const allFilteredSelected = visibleAssetIds.length > 0 && visibleAssetIds.every((id) => selectedIds.includes(id)); const canCreateAsset = page === 1 && viewMode === "library" && !keyword.trim() && kindFilter === "all" && categoryFilter === "all" && folderFilter === "all"; const totalAssets = assetPageQuery.data?.total ?? filteredAssets.length; const kindCounts = useMemo(() => assetCountMap(kindOptions, assetPageQuery.data?.kindCounts, viewMode === "trash" ? trashAssets : activeAssets, (asset) => asset.kind), [activeAssets, assetPageQuery.data?.kindCounts, trashAssets, viewMode]); const categoryCounts = useMemo(() => assetCountMap(categoryOptions, assetPageQuery.data?.categoryCounts, viewMode === "trash" ? trashAssets : activeAssets, (asset) => asset.category || "other"), [activeAssets, assetPageQuery.data?.categoryCounts, trashAssets, viewMode]); const folderCounts = assetPageQuery.data?.folderCounts || {}; useEffect(() => { const maxPage = Math.max(1, Math.ceil(totalAssets / pageSize)); setPage((value) => Math.min(value, maxPage)); }, [pageSize, totalAssets]); useEffect(() => { window.localStorage.setItem(ASSET_GRID_DENSITY_KEY, String(gridDensity)); }, [gridDensity]); useEffect(() => { const existingIds = new Set(validAssets.map((asset) => asset.id)); setSelectedIds((current) => current.filter((id) => existingIds.has(id))); }, [validAssets]); const folderSelectOptions = useMemo(() => [ { label: "未分类", value: "" }, ...folders.map((folder) => ({ label: folder.name, value: folder.id })), ], [folders]); const invalidateAssetLibrary = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ASSET_LIBRARY_QUERY_KEY }), queryClient.invalidateQueries({ queryKey: ASSET_FOLDER_QUERY_KEY }), ]); }; const saveFolder = async () => { const name = folderName.trim(); if (!name || !folderEditor) return; setFolderSaving(true); try { if (folderEditor === "new") await createAssetFolder(name); else await updateAssetFolder(folderEditor.id, name); setFolderEditor(null); setFolderName(""); await invalidateAssetLibrary(); message.success(folderEditor === "new" ? "素材分类已创建" : "素材分类已重命名"); } catch (error) { message.error(error instanceof Error ? error.message : "素材分类保存失败"); } finally { setFolderSaving(false); } }; const removeFolder = async (folder: AssetFolder) => { try { await deleteAssetFolder(folder.id); for (const asset of useAssetStore.getState().assets) { if (asset.folderId === folder.id) updateAsset(asset.id, { folderId: undefined }); } await flushAssetStorePersistence(); if (folderFilter === folder.id) setFolderFilter("all"); setPage(1); await invalidateAssetLibrary(); message.success(`已删除分类「${folder.name}」,其中素材已移至未分类`); } catch (error) { message.error(error instanceof Error ? error.message : "素材分类删除失败"); } }; const moveAssetsToFolder = async (assetIds: string[], folderId: string) => { if (!assetIds.length) return; try { await moveRemoteAssetsToFolder(assetIds, folderId); assetIds.forEach((id) => updateAsset(id, { folderId: folderId || undefined })); await flushAssetStorePersistence(); setSelectedIds([]); await invalidateAssetLibrary(); message.success(`已移动 ${assetIds.length} 个素材`); } catch (error) { message.error(error instanceof Error ? error.message : "移动素材失败"); } }; const openCreate = () => { setEditingAsset(null); setImageDraft(null); setImageFile(null); setImageUploading(false); setImageUploadProgress(null); setFormKind("text"); form.setFieldsValue({ kind: "text", category: "other", folderId: folderFilter !== "all" && folderFilter !== "uncategorized" ? folderFilter : "", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "", arkAssetId: "", portraitCertified: false }); setIsAssetOpen(true); }; const openEdit = (asset: LibraryAsset) => { setEditingAsset(asset); setImageFile(null); setImageUploading(false); setImageUploadProgress(null); setFormKind(asset.kind); setImageDraft(asset.kind === "image" ? asset.data : null); form.setFieldsValue({ kind: asset.kind, category: asset.category || "other", folderId: asset.folderId || "", title: asset.title, coverUrl: asset.coverUrl, tags: asset.tags || [], source: asset.source, note: asset.note, content: asset.kind === "text" ? asset.data.content : "", arkAssetId: asset.arkAssetId || "", portraitCertified: asset.portraitCertified === true, }); setIsAssetOpen(true); }; const saveAsset = async () => { const values = await form.validateFields(); let imageData = imageDraft; if (values.kind === "image" && imageFile) { setImageUploading(true); setImageUploadProgress({ phase: "uploading", percent: 0 }); try { const image = await uploadImage(imageFile); setImageUploadProgress({ phase: "confirming" }); imageData = { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType }; setImageDraft(imageData); setImageFile(null); void queryClient.invalidateQueries({ queryKey: assetStorageUsageQueryKey }); } catch (error) { message.error(error instanceof Error ? error.message : "图片上传失败,请重试"); return; } finally { setImageUploading(false); setImageUploadProgress(null); } } const base = { title: values.title.trim(), category: values.category, folderId: values.folderId || undefined, status: editingAsset?.status || ("confirmed" as const), primaryVersionId: editingAsset?.primaryVersionId, coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageData ? imageData.dataUrl : ""), tags: values.tags || [], source: values.source?.trim(), note: values.note?.trim(), arkAssetId: values.arkAssetId?.trim() || undefined, portraitCertified: values.portraitCertified || undefined, metadata: editingAsset?.metadata || { source: "manual" }, }; if (values.kind === "text") { const asset = { ...base, kind: "text" as const, data: { content: (values.content || "").trim() } }; editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset); } else { if (!imageData) { message.error("请选择图片文件"); return; } const asset = { ...base, kind: "image" as const, data: imageData }; editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset); } await flushAssetStorePersistence(); try { await saveRemoteUserDataNow(); await invalidateAssetLibrary(); message.success(editingAsset ? "素材已更新" : "素材已保存"); } catch (error) { message.warning(localSavedRemotePendingMessage(editingAsset ? "素材已在本地更新" : "素材已在本地保存", error)); } setIsAssetOpen(false); }; const readCoverFile = async (file?: File) => { if (!file) return; const dataUrl = await readFileAsDataUrl(file); form.setFieldValue("coverUrl", dataUrl); }; const readImageFile = async (file?: File) => { if (!file || !file.type.startsWith("image/") || imageUploading) return; try { const dataUrl = await readFileAsDataUrl(file); const meta = await readImageMeta(dataUrl); setImageFile(file); const draft = { dataUrl, storageKey: "", width: meta.width, height: meta.height, bytes: file.size, mimeType: file.type || meta.mimeType }; setImageDraft(draft); if (!form.getFieldValue("coverUrl")) form.setFieldValue("coverUrl", dataUrl); if (!form.getFieldValue("title")) form.setFieldValue("title", file.name); } catch (error) { message.error(error instanceof Error ? error.message : "读取图片失败,请重试"); } }; const readModelFile = async (file?: File) => { 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" }, }); // 直传失败时文件只落在本机,云端同步会重传;此时不能说成"已保存"。 if (uploaded.pendingRemoteUpload) message.warning(`3D 模型已保存在本机,尚未上传到服务器${uploaded.remoteUploadError ? `:${uploaded.remoteUploadError}` : ""}`); else message.success("3D 模型已保存"); }; const copyAssetText = async (asset: LibraryAsset) => { if (asset.kind !== "text") return; copyText(asset.data.content, "文本已复制"); }; const downloadImage = (asset: LibraryAsset) => { if (asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "audio" && asset.kind !== "model") return; const url = asset.kind === "image" ? asset.data.dataUrl : asset.data.url; const extension = asset.kind === "model" ? asset.data.fileName.split(".").pop() || "glb" : asset.data.mimeType.split("/")[1] || "png"; saveAs(url, `${asset.title || "asset"}.${extension}`); }; const exportAllAssets = async () => { if (!validAssets.length) { message.warning("暂无素材可导出"); return; } await exportAssets(validAssets); }; const importAssetZip = async (file?: File) => { if (!file) return; try { const importedAssets = await readAssetPackage(file); importedAssets.forEach((asset) => { const payload = { ...asset } as Record; delete payload.id; delete payload.createdAt; delete payload.updatedAt; addAsset(payload as Parameters[0]); }); message.success(`已导入 ${importedAssets.length} 个素材`); } catch { message.error("导入失败,请选择有效的素材压缩包"); } finally { if (assetInputRef.current) assetInputRef.current.value = ""; } }; const restoreAsset = async (asset: LibraryAsset) => { updateAsset(asset.id, { status: "confirmed" }); await flushAssetStorePersistence(); try { await saveRemoteUserDataNow(); message.success(`已还原素材「${asset.title}」`); } catch (error) { message.warning(localSavedRemotePendingMessage("已在本地还原", error)); } }; 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 (error) { message.warning(localSavedRemotePendingMessage("已在本地还原", error)); } }; const archiveAsset = async (asset: LibraryAsset) => { updateAsset(asset.id, { status: "archived" }); await flushAssetStorePersistence(); try { await saveRemoteUserDataNow(); message.success(`已将「${asset.title}」移入回收站`); } catch (error) { message.warning(localSavedRemotePendingMessage("已移入回收站", error)); } }; 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 (error) { message.warning(localSavedRemotePendingMessage("已移入回收站", error)); } }; 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("素材已彻底删除"); setDeletingAsset(null); } catch (error) { message.error(error instanceof Error ? error.message : "素材删除失败"); } }; const exportSelectedAssets = async () => { if (!selectedAssets.length) return; await exportAssets(selectedAssets); }; const confirmBatchDelete = async () => { if (!selectedAssets.length) return; try { for (const asset of selectedAssets) await deleteAssetWithRemoteSync(asset.id); message.success(`已彻底删除 ${selectedAssets.length} 个素材`); setSelectedIds([]); setBatchDeleteOpen(false); } catch (error) { message.error(error instanceof Error ? error.message : "批量删除失败"); } }; return ( <>
{validAssets.length} 个素材} actions={
{viewMode === "trash" ? ( <> {trashAssets.length > 0 ? ( void emptyTrash()} okText="清空" okButtonProps={{ danger: true }} cancelText="取消" > ) : null} ) : ( <>
} /> { setKeyword(""); setKindFilter("all"); setCategoryFilter("all"); setFolderFilter("all"); setPage(1); }} > } value={keyword} placeholder="搜索标题、内容、标签或来源" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} /> } options={[{ label: "舒适 · 6 列", value: 6 }, { label: "标准 · 8 列", value: 8 }, { label: "紧凑 · 10 列", value: 10 }]} onChange={(value) => setGridDensity(value as AssetGridDensity)} />
{viewMode === "trash" ? (
{retentionDays > 0 ? `回收站内的素材将在 ${retentionDays} 天后自动彻底清除。您可以随时还原素材,或手动彻底删除释放空间。` : "回收站内的素材当前设置为永久保留,您可以随时还原素材或手动彻底清除。"}
) : null} {selectedAssets.length ? ( setSelectedIds((current) => Array.from(new Set([...current, ...visibleAssetIds])))} onClear={() => setSelectedIds([])} onExport={() => void exportSelectedAssets()} onRestore={() => void batchRestore()} onArchive={() => setBatchArchiveOpen(true)} onDelete={() => setBatchDeleteOpen(true)} /> ) : null} {validAssets.length === 0 ? ( viewMode === "trash" ? ( ) : ( assetInputRef.current?.click()} onGoCanvas={() => navigate("/canvas")} /> ) ) : ( <> {filteredAssets.length === 0 ? ( ) : ( {canCreateAsset ? ( ) : null} {visibleAssets.map((asset) => ( 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)} folderOptions={folderSelectOptions} onMoveToFolder={(folderId) => void moveAssetsToFolder([asset.id], folderId)} /> ))} )} { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} /> )}
{ if (!imageUploading) setIsAssetOpen(false); }} onOk={() => void saveAsset()} okText={imageUploading ? "正在上传" : "保存"} cancelText="取消" confirmLoading={imageUploading} cancelButtonProps={{ disabled: imageUploading }} closable={!imageUploading} destroyOnHidden >
{formKind === "text" ? ( ) : (
{imageFile ? ( 待保存上传 ) : null} {imageDraft ? ( {imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)} · {resourceStorageLabel(imageDraft.storageKey)} ) : ( 未选择图片 )}
)}
预览
{coverUrl || imageDraft?.dataUrl ? (
{imageUploading && imageUploadProgress ? (
{imageUploadProgress.phase === "confirming" ? "正在确认资源" : "正在上传到云端"} {typeof imageUploadProgress.percent === "number" ? {imageUploadProgress.percent}% : null}
) : null}
) : (
{content || "暂无封面"}
)}
{title || "未命名素材"}
{tags.length ? ( tags.map((tag) => ( {tag} )) ) : ( 未打标签 )}
{ void readCoverFile(event.target.files?.[0]); event.target.value = ""; }} /> { void readImageFile(event.target.files?.[0]); event.target.value = ""; }} />
setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} /> setBatchUploadOpen(false)} onComplete={async () => { setBatchUploadOpen(false); await invalidateAssetLibrary(); }} /> { if (!folderSaving) setFolderEditor(null); }} onOk={() => void saveFolder()} okText="保存" cancelText="取消" > setFolderName(event.target.value)} onPressEnter={() => void saveFolder()} /> void importAssetZip(event.target.files?.[0])} /> { void readModelFile(event.target.files?.[0]); event.currentTarget.value = ""; }} /> setArchivingAsset(null)} onOk={() => { if (archivingAsset) { void archiveAsset(archivingAsset); setArchivingAsset(null); } }} okText="移入回收站" cancelText="取消" > 确定将「{archivingAsset?.title}」移入回收站吗?移入后不会出现在正常素材库中,可在回收站随时还原。 setBatchArchiveOpen(false)} onOk={() => { void batchArchive(); setBatchArchiveOpen(false); }} okText="移入回收站" cancelText="取消" > 确定将已选择的 {selectedAssets.length} 个素材移入回收站吗?移入后可随时在回收站批量还原。 setDeletingAsset(null)} onOk={() => void confirmDelete()} okText="彻底删除" okButtonProps={{ danger: true }} cancelText="取消" > 确定彻底删除「{deletingAsset?.title}」吗?未被其他内容引用的服务器本地或对象存储文件也会同步删除,操作不可恢复。 setBatchDeleteOpen(false)} onOk={() => void confirmBatchDelete()} okText="彻底删除" okButtonProps={{ danger: true }} cancelText="取消" > 确定彻底删除已选择的 {selectedAssets.length} 个素材吗?未被复用的服务器文件会同步删除,操作不可恢复。 ); } 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, folderOptions, onMoveToFolder, }: { 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; folderOptions: Array<{ label: string; value: string }>; onMoveToFolder: (folderId: string) => void; }) { const summary = assetSummary(asset); const menuItems: MenuProps["items"] = isTrash ? [{ key: "restore", icon: , label: "还原到素材库", onClick: onRestore }, { type: "divider" as const }, { key: "delete", danger: true, icon: , label: "彻底删除", onClick: onDelete }] : [ ...(asset.kind === "text" || asset.kind === "image" ? [{ key: "edit", icon: , label: "编辑", onClick: onEdit }] : []), ...(asset.kind === "text" ? [{ key: "copy", icon: , label: "复制文本", onClick: () => void onCopy(asset) }] : []), ...(asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? [{ key: "download", icon: , label: "下载", onClick: () => onDownload(asset) }] : []), { key: "move", icon: , label: "移动到分类", children: folderOptions.map((folder) => ({ key: folder.value || "uncategorized", label: folder.label, onClick: () => onMoveToFolder(folder.value) })) }, { type: "divider" as const }, { key: "archive", icon: , label: "移入回收站", onClick: onArchive }, { key: "delete", danger: true, icon: , label: "彻底删除", onClick: onDelete }, ]; return ( ); } function isKnownAssetKind(kind: unknown): kind is AssetKind { return kind === "image" || kind === "video" || kind === "audio" || kind === "model" || kind === "text"; } 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 kind = isKnownAssetKind(asset.kind) ? asset.kind : undefined; const KindIcon = kind ? assetKindIcons[kind] : FileText; const clock = asset.kind === "video" || asset.kind === "audio" ? formatAssetClock(asset.data.durationMs) : null; const showPlay = asset.kind === "video"; const isLight = asset.kind === "audio" || asset.kind === "text" || asset.kind === "model"; return ( {kind ? assetKindLabel(kind) : "素材"} {isTrash ? 回收站 : {assetCategoryLabel(asset.category)}} {asset.portraitCertified ? 人像认证 : null} {clock ? {clock} : null} event.stopPropagation()} onChange={(event) => onSelect(event.target.checked)} className="assets-select-check" aria-label={`选择 ${asset.title}`} /> ); } function AudioWaveCover({ asset }: { asset: LibraryAsset & { kind: "audio" } }) { const bars = audioWaveBars(asset.id); return ( ); } function TextCover({ asset }: { asset: LibraryAsset & { kind: "text" } }) { return (

{asset.data.content || "空白文本素材"}

); } function ModelCover({ asset }: { asset: LibraryAsset & { kind: "model" } }) { return (
{asset.data.fileName}
); } 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 (
已选择 {count} 个素材
{isTrash ? ( <> ) : ( <> )}
); } const assetsEmptyBannerFrames = [ { src: "/short-drama-styles/retro-hong-kong.jpg", caption: "ASSET.01 · 天台重逢" }, { src: "/short-drama-styles/cyberpunk-neon.jpg", caption: "ASSET.02 · 雨夜霓虹" }, { src: "/short-drama-styles/suspense-noir.jpg", caption: "ASSET.03 · 暗巷追逐" }, ]; function AssetsEmptyState({ onNew, onImport, onGoCanvas }: { onNew: () => void; onImport: () => void; onGoCanvas: () => void }) { const brandName = useAppearanceStore((state) => state.appearance.brandName); return (
); } function AssetFilterGroup({ title, options, value, counts, onChange, className = "", }: { title: string; options: Array<{ label: string; value: string }>; value: string; counts: Map; onChange: (value: string) => void; className?: string; }) { return (
{title}
{options.map((option) => { const active = value === option.value; return ( ); })}
); } function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAsset | null; onClose: () => void; onCopy: (asset: LibraryAsset) => void; onDownload: (asset: LibraryAsset) => void }) { const facts = asset ? assetArchiveFacts(asset) : []; const kind = asset && isKnownAssetKind(asset.kind) ? asset.kind : undefined; const KindIcon = asset ? (kind ? assetKindIcons[kind] : FileText) : Clapperboard; return ( {asset ? (

{asset.title}

{assetCategoryLabel(asset.category)} · {formatAssetDateTime(asset.createdAt)} 创建

{asset.kind === "text" ? (
{asset.data.content}
) : asset.kind === "audio" ? (
) : asset.kind === "model" ? (
{asset.data.fileName} · {formatBytes(asset.data.bytes)}
) : asset.kind === "video" ? (
{(asset.tags || []).map((tag) => ( {tag} ))} {asset.arkAssetId ? ( 方舟 {asset.arkAssetId} ) : null}
{facts.map((fact) => (
{fact.label} {fact.value}
))}
所属项目 {assetProjectLabel(asset)}
{asset.note ? (
备注

{asset.note}

) : null}
{asset.kind === "text" ? ( ) : null} {asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? ( ) : null}
) : null}
); } function AssetImageZoom({ asset }: { asset: LibraryAsset & { kind: "image" } }) { const [scale, setScale] = useState(1); const [offset, setOffset] = useState({ x: 0, y: 0 }); const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null); const reset = () => { setScale(1); setOffset({ x: 0, y: 0 }); }; return (
{ event.preventDefault(); setScale((value) => Math.min(4, Math.max(.25, value * (event.deltaY < 0 ? 1.12 : .89)))); }} onPointerDown={(event) => { if (scale <= 1) return; event.currentTarget.setPointerCapture(event.pointerId); dragRef.current = { x: event.clientX, y: event.clientY, ox: offset.x, oy: offset.y }; }} onPointerMove={(event) => { const drag = dragRef.current; if (!drag) return; setOffset({ x: drag.ox + event.clientX - drag.x, y: drag.oy + event.clientY - drag.y }); }} onPointerUp={() => { dragRef.current = null; }} onPointerCancel={() => { dragRef.current = null; }}> {asset.title}
); } function assetArchiveFacts(asset: LibraryAsset) { const facts: Array<{ label: string; value: string }> = [ { label: "类型", value: assetKindLabel(asset.kind) }, { label: "分类", value: assetCategoryLabel(asset.category) }, ]; if (asset.kind === "image" || asset.kind === "video") { facts.push({ label: "尺寸", value: `${asset.data.width}x${asset.data.height}` }); } if (asset.kind === "video" || asset.kind === "audio") { facts.push({ label: "时长", value: formatAssetClock(asset.data.durationMs) || "未知" }); } if (asset.kind !== "text") { facts.push({ label: "大小", value: formatBytes(asset.data.bytes) }); facts.push({ label: "格式", value: asset.data.mimeType }); facts.push({ label: "存储", value: resourceStorageLabel(asset.data.storageKey) }); } facts.push({ label: "来源", value: asset.source || "未标注" }); facts.push({ label: "创建", value: formatAssetDateTime(asset.createdAt) }); facts.push({ label: "更新", value: formatAssetDateTime(asset.updatedAt) }); return facts; } function assetSummary(asset: LibraryAsset) { if (asset.kind === "text") return asset.data.content; if (asset.kind === "audio") return `${formatAssetDuration(asset.data.durationMs)} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`; if (asset.kind === "model") return `${asset.data.fileName} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`; return `${asset.data.width}x${asset.data.height} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`; } function StorageTag({ asset }: { asset: LibraryAsset }) { if (asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "audio" && asset.kind !== "model") return null; const location = resourceStorageLocation(asset.data.storageKey); const color = location === "oss" ? "green" : location === "local" ? "gold" : "default"; return ( {resourceStorageLabel(asset.data.storageKey)} ); } function assetSearchText(asset: LibraryAsset) { return [asset.title, asset.source || "", asset.note || "", assetCategoryLabel(asset.category), (asset.tags || []).join(" "), asset.kind === "text" ? asset.data.content : asset.data.mimeType].join(" ").toLowerCase(); } function assetProjectLabel(asset: LibraryAsset) { const projectName = asset.metadata?.projectName; if (typeof projectName === "string" && projectName.trim()) return projectName; return Array.isArray(asset.metadata?.projectIds) && asset.metadata.projectIds.length ? "已关联项目" : "未关联项目"; } function assetKindLabel(kind: AssetKind) { return kind === "image" ? "图片" : kind === "video" ? "视频" : kind === "audio" ? "音频" : kind === "model" ? "3D 模型" : "文本"; } function assetDownloadLabel(asset: LibraryAsset) { if (asset.kind === "video") return "下载视频"; if (asset.kind === "audio") return "下载音频"; if (asset.kind === "model") return "下载模型"; return "下载图片"; } function readAssetGridDensity(): AssetGridDensity { if (typeof window === "undefined") return 8; const value = Number(window.localStorage.getItem(ASSET_GRID_DENSITY_KEY)); return value === 6 || value === 10 ? value : 8; } function assetCountMap(options: T[], remote: Record | undefined, fallback: LibraryAsset[], valueOf: (asset: LibraryAsset) => string) { const result = new Map(); options.forEach((option) => { if (remote) result.set(option.value, option.value === "all" ? Object.values(remote).reduce((sum, count) => sum + count, 0) : remote[option.value] || 0); else result.set(option.value, option.value === "all" ? fallback.length : fallback.filter((asset) => valueOf(asset) === option.value).length); }); return result; } function formatAssetDuration(durationMs?: number) { if (!durationMs) return "时长未知"; return `${Math.round(durationMs / 100) / 10} 秒`; } function formatAssetClock(durationMs?: number) { if (!durationMs || durationMs < 1000) return null; const total = Math.round(durationMs / 1000); const minutes = Math.floor(total / 60); const seconds = total % 60; return `${minutes}:${String(seconds).padStart(2, "0")}`; } function formatAssetTime(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" }); } function formatAssetDateTime(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) return "-"; return date.toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); } function audioWaveBars(seed: string) { let hash = 0; for (const char of seed) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; const bars: number[] = []; for (let index = 0; index < 26; index += 1) { hash = (hash * 9301 + 49297) % 233280; const random = hash / 233280; const envelope = 0.35 + 0.65 * Math.abs(Math.sin(index * 0.55 + 1.2)); bars.push(Math.round((0.18 + 0.82 * random * envelope) * 100)); } return bars; }