- 素材编辑弹窗新增「方舟素材 ID」(asset- 前缀校验)与「人像认证」开关, 卡片封面显示人像认证徽章,详情抽屉展示方舟素材 ID - 画布图片节点绑定的素材录入方舟素材 ID 后,方舟视频渠道(volcengine-ark-video 或 Ark Plan BaseURL)生成时参考图直接提交 asset:// 引用,跳过可信素材上传同步; 浏览器直连路径同样优先 asset://;非方舟渠道与本地回退路径不受影响 - 素材字段随 payload_json 透传,AssetBase 与 parseAssetRecord 白名单同步扩展
1564 lines
84 KiB
TypeScript
1564 lines
84 KiB
TypeScript
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<Asset, { kind: "entity" }>;
|
||
|
||
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<LibraryAsset["kind"], LucideIcon> = {
|
||
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<AssetFormValues>();
|
||
const coverInputRef = useRef<HTMLInputElement>(null);
|
||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||
const assetInputRef = useRef<HTMLInputElement>(null);
|
||
const modelInputRef = useRef<HTMLInputElement>(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<AssetKind | "all">("all");
|
||
const [categoryFilter, setCategoryFilter] = useState<AssetCategory | "all">("all");
|
||
const [folderFilter, setFolderFilter] = useState<AssetFolderFilter>("all");
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(40);
|
||
const [gridDensity, setGridDensity] = useState<AssetGridDensity>(readAssetGridDensity);
|
||
const [editingAsset, setEditingAsset] = useState<LibraryAsset | null>(null);
|
||
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 [batchUploadOpen, setBatchUploadOpen] = useState(false);
|
||
const [folderEditor, setFolderEditor] = useState<AssetFolder | "new" | null>(null);
|
||
const [folderName, setFolderName] = useState("");
|
||
const [folderSaving, setFolderSaving] = useState(false);
|
||
|
||
const [formKind, setFormKind] = useState<AssetKind>("text");
|
||
const [imageDraft, setImageDraft] = useState<ImageDraft>(null);
|
||
const [imageFile, setImageFile] = useState<File | null>(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<string, unknown>;
|
||
delete payload.id;
|
||
delete payload.createdAt;
|
||
delete payload.updatedAt;
|
||
addAsset(payload as Parameters<typeof addAsset>[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 (
|
||
<>
|
||
<WorkspacePage grid className="library-page assets-library-page canvas-library-page">
|
||
<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 icon={<Plus className="size-3.5" />} onClick={openCreate}>
|
||
新增素材
|
||
</Button>
|
||
<Button icon={<Images className="size-3.5" />} onClick={() => setBatchUploadOpen(true)}>
|
||
批量上传图片
|
||
</Button>
|
||
<Button icon={<FolderOpen className="size-3.5" />} onClick={() => navigate("/plugins/eagle")}>
|
||
Eagle 素材库
|
||
</Button>
|
||
<Button aria-label="导出全部素材" title="导出全部素材" icon={<Download className="size-3.5" />} 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 aria-label="导入素材" title="导入素材" icon={<FileUp className="size-3.5" />} />
|
||
</Dropdown>
|
||
</>
|
||
)}
|
||
</div>
|
||
<AssetStorageUsage />
|
||
</div>
|
||
}
|
||
/>
|
||
<ListToolbar
|
||
className="library-toolbar"
|
||
active={Boolean(keyword || kindFilter !== "all" || categoryFilter !== "all")}
|
||
onReset={() => {
|
||
setKeyword("");
|
||
setKindFilter("all");
|
||
setCategoryFilter("all");
|
||
setFolderFilter("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);
|
||
}}
|
||
/>
|
||
<Select
|
||
value={folderFilter}
|
||
className="w-full sm:w-44"
|
||
options={[{ label: "全部自定义分类", value: "all" }, { label: "未分类", value: "uncategorized" }, ...folders.map((folder) => ({ label: folder.name, value: folder.id }))]}
|
||
onChange={(value) => {
|
||
setFolderFilter(value as AssetFolderFilter);
|
||
setPage(1);
|
||
}}
|
||
/>
|
||
<Select
|
||
value={gridDensity}
|
||
className="w-full sm:w-32"
|
||
suffixIcon={<LayoutGrid className="size-3.5" />}
|
||
options={[{ label: "舒适 · 6 列", value: 6 }, { label: "标准 · 8 列", value: 8 }, { label: "紧凑 · 10 列", value: 10 }]}
|
||
onChange={(value) => setGridDensity(value as AssetGridDensity)}
|
||
/>
|
||
</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-5">
|
||
<div className="mb-1.5 flex items-center justify-between px-1 text-[var(--fs-tiny)] font-semibold uppercase tracking-[0.08em] text-foreground/38">
|
||
<span>我的分类</span>
|
||
<button type="button" className="assets-folder-add" title="新建分类" aria-label="新建分类" onClick={() => { setFolderName(""); setFolderEditor("new"); }}><FolderPlus className="size-3.5" /></button>
|
||
</div>
|
||
<div className="space-y-0.5">
|
||
<button type="button" aria-pressed={folderFilter === "uncategorized"} className={`assets-filter-item w-full ${folderFilter === "uncategorized" ? "is-active" : ""}`} onClick={() => { setFolderFilter("uncategorized"); setPage(1); }}>
|
||
<span className="assets-filter-item-label">未分类</span><span className="assets-filter-count">{folderCounts[""] ?? activeAssets.filter((asset) => !asset.folderId).length}</span>
|
||
</button>
|
||
{folders.map((folder) => (
|
||
<div key={folder.id} className="assets-folder-row">
|
||
<button type="button" aria-pressed={folderFilter === folder.id} className={`assets-filter-item min-w-0 flex-1 ${folderFilter === folder.id ? "is-active" : ""}`} onClick={() => { setFolderFilter(folder.id); setPage(1); }}>
|
||
<span className="assets-filter-item-label min-w-0 truncate">{folder.name}</span><span className="assets-filter-count">{folderCounts[folder.id] ?? activeAssets.filter((asset) => asset.folderId === folder.id).length}</span>
|
||
</button>
|
||
<Dropdown trigger={["click"]} menu={{ items: [{ key: "rename", icon: <PencilLine className="size-3.5" />, label: "重命名", onClick: () => { setFolderName(folder.name); setFolderEditor(folder); } }, { key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除分类", onClick: () => void removeFolder(folder) }] }}>
|
||
<button type="button" className="assets-folder-more" aria-label={`管理分类 ${folder.name}`} title="管理分类"><MoreHorizontal className="size-3.5" /></button>
|
||
</Dropdown>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<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, ...visibleAssetIds])))}
|
||
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" style={{ "--assets-grid-columns": gridDensity } as React.CSSProperties}>
|
||
{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)}
|
||
folderOptions={folderSelectOptions}
|
||
onMoveToFolder={(folderId) => void moveAssetsToFolder([asset.id], folderId)}
|
||
/>
|
||
))}
|
||
</CollectionGrid>
|
||
)}
|
||
<PaginationBar
|
||
current={page}
|
||
pageSize={pageSize}
|
||
total={totalAssets}
|
||
pageSizeOptions={[40, 80, 120]}
|
||
onChange={(nextPage, nextPageSize) => {
|
||
setPage(nextPageSize !== pageSize ? 1 : nextPage);
|
||
setPageSize(nextPageSize);
|
||
}}
|
||
/>
|
||
</>
|
||
)}
|
||
</section>
|
||
</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
|
||
>
|
||
<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="类型">
|
||
<Select
|
||
options={[
|
||
{ label: "文本", value: "text" },
|
||
{ label: "图片", value: "image" },
|
||
]}
|
||
onChange={(value) => setFormKind(value)}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="category" label="业务分类">
|
||
<Select options={categoryOptions.slice(1)} />
|
||
</Form.Item>
|
||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||
<Input placeholder="给素材起一个容易检索的名字" />
|
||
</Form.Item>
|
||
<Form.Item name="coverUrl" label="封面 URL">
|
||
<Space.Compact className="w-full">
|
||
<Input placeholder="可粘贴图片 URL,也可以上传本地封面" />
|
||
<Button icon={<Upload className="size-3.5" />} onClick={() => coverInputRef.current?.click()}>
|
||
上传
|
||
</Button>
|
||
</Space.Compact>
|
||
</Form.Item>
|
||
<Form.Item name="tags" label="标签">
|
||
<Select mode="tags" tokenSeparators={[",", ","]} placeholder="输入标签后回车" />
|
||
</Form.Item>
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<Form.Item name="arkAssetId" label="方舟素材 ID" rules={[{ pattern: /^asset-[A-Za-z0-9-]+$/, message: "请输入 asset- 开头的方舟素材 ID" }]}>
|
||
<Input autoComplete="off" allowClear placeholder="asset-…,需为本人或被授权可用的方舟素材" />
|
||
</Form.Item>
|
||
<Form.Item name="portraitCertified" label="人像认证" valuePropName="checked" extra="标记已通过火山方舟实人认证的真人人像素材">
|
||
<Switch aria-label="人像认证" />
|
||
</Form.Item>
|
||
</div>
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<Form.Item name="source" label="来源">
|
||
<Input placeholder="手动添加 / 画布 / 任务中心" />
|
||
</Form.Item>
|
||
<Form.Item name="note" label="备注">
|
||
<Input placeholder="可选" />
|
||
</Form.Item>
|
||
</div>
|
||
{formKind === "text" ? (
|
||
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
|
||
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本素材" />
|
||
</Form.Item>
|
||
) : (
|
||
<Form.Item label="图片内容" required>
|
||
<div className="rounded-lg border border-dashed border-stone-300 p-4 dark:border-stone-700">
|
||
<Button disabled={imageUploading} icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}>
|
||
{imageUploading ? "正在上传图片" : "选择图片文件"}
|
||
</Button>
|
||
{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)}
|
||
</Typography.Text>
|
||
) : (
|
||
<Typography.Text type="secondary" className="ml-3 text-xs">
|
||
未选择图片
|
||
</Typography.Text>
|
||
)}
|
||
</div>
|
||
</Form.Item>
|
||
)}
|
||
</Form>
|
||
<div className="lg:pl-4">
|
||
<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" : ""}`}>
|
||
<img src={coverUrl || imageDraft?.dataUrl} alt="" loading="lazy" decoding="async" className="aspect-[4/3] w-full object-cover" />
|
||
{imageUploading && imageUploadProgress ? (
|
||
<div className="asset-preview-uploading-panel">
|
||
<div className="asset-preview-uploading-copy">
|
||
<span>{imageUploadProgress.phase === "confirming" ? "正在确认资源" : "正在上传到云端"}</span>
|
||
{typeof imageUploadProgress.percent === "number" ? <strong>{imageUploadProgress.percent}%</strong> : null}
|
||
</div>
|
||
<Progress percent={imageUploadProgress.percent} showInfo={false} size="small" status="active" />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm text-stone-500 dark:bg-stone-900">{content || "暂无封面"}</div>
|
||
)}
|
||
<div className="bg-background p-3">
|
||
<Typography.Text strong ellipsis className="block">
|
||
{title || "未命名素材"}
|
||
</Typography.Text>
|
||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||
{tags.length ? (
|
||
tags.map((tag) => (
|
||
<Tag key={tag} className="m-0">
|
||
{tag}
|
||
</Tag>
|
||
))
|
||
) : (
|
||
<Tag className="m-0">未打标签</Tag>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<input
|
||
ref={coverInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(event) => {
|
||
void readCoverFile(event.target.files?.[0]);
|
||
event.target.value = "";
|
||
}}
|
||
/>
|
||
<input
|
||
ref={imageInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(event) => {
|
||
void readImageFile(event.target.files?.[0]);
|
||
event.target.value = "";
|
||
}}
|
||
/>
|
||
</Modal>
|
||
|
||
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
|
||
|
||
<AssetBatchUploadModal open={batchUploadOpen} defaultFolderId={folderFilter !== "all" && folderFilter !== "uncategorized" ? folderFilter : ""} folders={folders} onClose={() => setBatchUploadOpen(false)} onComplete={async () => { setBatchUploadOpen(false); await invalidateAssetLibrary(); }} />
|
||
|
||
<Modal
|
||
className="library-modal library-confirm-modal"
|
||
title={folderEditor === "new" ? "新建分类" : "重命名分类"}
|
||
open={Boolean(folderEditor)}
|
||
confirmLoading={folderSaving}
|
||
onCancel={() => { if (!folderSaving) setFolderEditor(null); }}
|
||
onOk={() => void saveFolder()}
|
||
okText="保存"
|
||
cancelText="取消"
|
||
>
|
||
<Input autoFocus value={folderName} maxLength={40} placeholder="例如:角色参考、场景灵感" onChange={(event) => setFolderName(event.target.value)} onPressEnter={() => void saveFolder()} />
|
||
</Modal>
|
||
|
||
<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 = "";
|
||
}}
|
||
/>
|
||
|
||
<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={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 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: <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) }] : []),
|
||
{ key: "move", icon: <FolderOpen className="size-3.5" />, label: "移动到分类", children: folderOptions.map((folder) => ({ key: folder.value || "uncategorized", label: folder.label, onClick: () => onMoveToFolder(folder.value) })) },
|
||
{ 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} 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>
|
||
<span className="shrink-0 text-[var(--fs-tiny)] tabular-nums text-foreground/38">{formatAssetTime(asset.updatedAt)}</span>
|
||
</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>
|
||
<span className="truncate">{assetProjectLabel(asset)}</span>
|
||
</div>
|
||
</button>
|
||
</AssetLibraryCard>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<AssetLibraryCardMedia className={isLight ? "assets-cover is-light" : "assets-cover"}>
|
||
<button type="button" className="assets-cover-link" onClick={onOpen} aria-label={`查看素材:${asset.title}`}>
|
||
{asset.kind === "audio" ? (
|
||
<AudioWaveCover asset={asset} />
|
||
) : asset.kind === "text" ? (
|
||
<TextCover asset={asset} />
|
||
) : 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>
|
||
}
|
||
/>
|
||
)}
|
||
<span className="assets-cover-vignette" aria-hidden="true" />
|
||
{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 />
|
||
{kind ? assetKindLabel(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>}
|
||
{asset.portraitCertified ? <span className="assets-cover-badge is-category">人像认证</span> : null}
|
||
</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 }}>
|
||
<button type="button" className="assets-cover-more" aria-label="更多素材操作" title="更多操作">
|
||
<MoreHorizontal className="size-4" />
|
||
</button>
|
||
</Dropdown>
|
||
</AssetLibraryCardMedia>
|
||
);
|
||
}
|
||
|
||
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}%` }} />
|
||
))}
|
||
<AudioLines className="assets-cover-wave-glyph" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TextCover({ asset }: { asset: LibraryAsset & { kind: "text" } }) {
|
||
return (
|
||
<div className="assets-cover-text">
|
||
<p>{asset.data.content || "空白文本素材"}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ModelCover({ asset }: { asset: LibraryAsset & { kind: "model" } }) {
|
||
return (
|
||
<div className="assets-cover-model">
|
||
<Box />
|
||
<span>{asset.data.fileName}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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>
|
||
<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>
|
||
{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>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="assets-empty">
|
||
<div className="assets-empty-banner" aria-hidden="true">
|
||
{assetsEmptyBannerFrames.map((frame, index) => (
|
||
<figure key={frame.caption} className={`assets-empty-banner-frame ${index === 1 ? "is-main" : index === 0 ? "is-back" : "is-front"}`}>
|
||
<img src={frame.src} alt="" loading="lazy" decoding="async" />
|
||
<span>{frame.caption}</span>
|
||
</figure>
|
||
))}
|
||
<span className="assets-empty-banner-caption">
|
||
<span>{brandName}素材库</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>
|
||
<strong>新建素材</strong>
|
||
<span>录入提示词、说明文案,或上传图片资产。</span>
|
||
</button>
|
||
<button type="button" className="assets-empty-card" onClick={onImport}>
|
||
<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>
|
||
<strong>去画布保存</strong>
|
||
<span>把画布上满意的镜头与画面留档进素材库。</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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>
|
||
<div className="flex gap-1.5 lg:block lg:space-y-0.5">
|
||
{options.map((option) => {
|
||
const active = value === option.value;
|
||
return (
|
||
<button key={option.value} type="button" aria-pressed={active} className={`assets-filter-item ${active ? "is-active" : ""}`} onClick={() => onChange(option.value)}>
|
||
<span className="assets-filter-item-label">{option.label}</span>
|
||
<span className="assets-filter-count">{counts.get(option.value) || 0}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Drawer className="library-drawer" title="素材档案" open={Boolean(asset)} size="large" onClose={onClose}>
|
||
{asset ? (
|
||
<div className="space-y-4">
|
||
<div className="asset-archive-header">
|
||
<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>
|
||
</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>
|
||
) : asset.kind === "model" ? (
|
||
<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" />
|
||
) : (
|
||
<AssetImageZoom asset={asset} />
|
||
)}
|
||
</div>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{(asset.tags || []).map((tag) => (
|
||
<Tag key={tag} className="m-0">
|
||
{tag}
|
||
</Tag>
|
||
))}
|
||
{asset.arkAssetId ? (
|
||
<Tag className="m-0" color="geekblue" title="火山方舟素材 ID,生成视频时可直接 asset:// 引用">
|
||
方舟 {asset.arkAssetId}
|
||
</Tag>
|
||
) : null}
|
||
<StorageTag asset={asset} />
|
||
</div>
|
||
<div className="asset-archive-facts">
|
||
{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>
|
||
</div>
|
||
))}
|
||
</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>
|
||
<p className="asset-archive-section-body">{asset.note}</p>
|
||
</div>
|
||
) : null}
|
||
<div className="asset-archive-actions">
|
||
{asset.kind === "text" ? (
|
||
<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>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</Drawer>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="asset-zoom-viewer" onWheel={(event) => { 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; }}>
|
||
<img src={asset.coverUrl || asset.data.dataUrl} alt={asset.title} loading="lazy" decoding="async" className="asset-archive-preview-media asset-zoom-image" style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})` }} />
|
||
<div className="asset-zoom-controls" data-canvas-no-zoom>
|
||
<button type="button" title="缩小" aria-label="缩小" onClick={() => setScale((value) => Math.max(.25, value / 1.25))}><ZoomOut className="size-4" /></button>
|
||
<button type="button" title="恢复适应" aria-label="恢复适应" onClick={reset}>{Math.round(scale * 100)}%</button>
|
||
<button type="button" title="放大" aria-label="放大" onClick={() => setScale((value) => Math.min(4, value * 1.25))}><ZoomIn className="size-4" /></button>
|
||
<button type="button" title="查看原图尺寸" aria-label="查看原图尺寸" onClick={() => setScale(1)}><Maximize2 className="size-4" /></button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Tag color={color} className="m-0 text-[var(--fs-label)]" title={resourceStorageTitle(asset.data.storageKey)}>
|
||
{resourceStorageLabel(asset.data.storageKey)}
|
||
</Tag>
|
||
);
|
||
}
|
||
|
||
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<T extends { label: string; value: string }>(options: T[], remote: Record<string, number> | undefined, fallback: LibraryAsset[], valueOf: (asset: LibraryAsset) => string) {
|
||
const result = new Map<string, number>();
|
||
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;
|
||
}
|