import { ClockCircleOutlined, CloseOutlined, DatabaseOutlined, DeleteOutlined, FileImageOutlined, LoadingOutlined, LoginOutlined, PlusOutlined, SearchOutlined, UserOutlined, } from "@ant-design/icons"; import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react"; import "../../styles/pages/assets.css"; import { assetClient, type ServerAssetItem } from "../../api/assetClient"; import { aiGenerationClient } from "../../api/aiGenerationClient"; import { useDebounce } from "../../hooks/useDebounce"; import OptimizedImage from "../../components/OptimizedImage"; import { SkeletonList } from "../../components/Skeleton"; import { EmptyState } from "../../components/EmptyState"; import WorkspacePageShell from "../../components/WorkspacePageShell"; import type { WebAssetItem } from "../../types"; type AssetTypeFilter = WebAssetItem["type"] | "all"; type LibraryAssetItem = WebAssetItem & { project: string; version: string; ratio: string; tags: string[]; thumbClass: string; }; interface AssetsPageProps { isAuthenticated: boolean; onOpenLogin: () => void; } const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: JSX.Element | null }> = [ { key: "all", label: "全部", icon: null }, { key: "character", label: "人物", icon: }, { key: "scene", label: "场景", icon: }, { key: "prop", label: "物品", icon: }, { key: "video", label: "视频", icon: }, { key: "image", label: "图片", icon: }, { key: "asset", label: "素材", icon: }, { key: "other", label: "其他", icon: }, ]; const statusLabel: Record = { ready: "已完成", draft: "草稿", reviewing: "复核中", pending: "同步中", failed: "失败", }; const statusBadgeClass: Record = { ready: "studio-status-bar__badge--success", draft: "studio-status-bar__badge--idle", reviewing: "studio-status-bar__badge--running", pending: "studio-status-bar__badge--running", failed: "studio-status-bar__badge--danger", }; function mapServerAsset(asset: ServerAssetItem): LibraryAssetItem { return { ...asset, project: asset.sourceProjectId || "服务器资产库", version: "Server", ratio: String(asset.metadata?.ratio || "1:1"), tags: asset.tags?.length ? asset.tags : ["服务器素材"], thumbClass: asset.imageUrl ? "is-uploaded" : "is-station", }; } function getAssetPreviewUrl(asset: LibraryAssetItem) { return asset.url || asset.imageUrl; } function isVideoPreview(asset: LibraryAssetItem) { const url = getAssetPreviewUrl(asset) || ""; return asset.type === "video" || /\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(url); } function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) { const [activeType, setActiveType] = useState("all"); const [query, setQuery] = useState(""); const debouncedQuery = useDebounce(query, 300); const [serverAssets, setServerAssets] = useState([]); const [serverNotice, setServerNotice] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isUploading, setIsUploading] = useState(false); const [previewAsset, setPreviewAsset] = useState(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; asset: LibraryAssetItem } | null>(null); const contextMenuRef = useRef(null); const uploadInputRef = useRef(null); const handleContextMenu = useCallback((e: React.MouseEvent, asset: LibraryAssetItem) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, asset }); }, []); const handleDeleteAsset = useCallback(async (asset?: LibraryAssetItem) => { const target = asset || contextMenu?.asset; if (!target) return; setContextMenu(null); try { await assetClient.delete(target.id); setServerAssets((prev) => prev.filter((a) => a.id !== target.id)); setServerNotice(`已删除 ${target.name}`); } catch (err) { setServerNotice(err instanceof Error ? err.message : "删除失败"); } }, [contextMenu]); const handleUploadFiles = useCallback(async (files: FileList | null) => { if (!files || files.length === 0) return; setIsUploading(true); setServerNotice("正在上传..."); let successCount = 0; for (const file of Array.from(files)) { try { const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(new Error("读取文件失败")); reader.readAsDataURL(file); }); const mimeType = file.type || (file.name.match(/\.(mp4|mov|webm)$/i) ? "video/mp4" : "image/png"); const uploaded = await aiGenerationClient.uploadAsset({ dataUrl, name: file.name, mimeType, scope: "asset-library" }); const isVideo = mimeType.startsWith("video/"); const created = await assetClient.create({ type: isVideo ? "video" : "image", name: file.name.replace(/\.[^.]+$/, ""), description: `手动上传 · ${file.name}`, url: uploaded.url, imageUrl: isVideo ? "" : uploaded.url, status: "ready", tags: ["手动上传"], }); setServerAssets((prev) => [created, ...prev]); successCount++; } catch (err) { setServerNotice(err instanceof Error ? err.message : `上传 ${file.name} 失败`); } } setIsUploading(false); if (successCount > 0) setServerNotice(`已上传 ${successCount} 个文件`); if (uploadInputRef.current) uploadInputRef.current.value = ""; }, []); useEffect(() => { if (!contextMenu) return undefined; const handleClick = (e: MouseEvent) => { if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) { setContextMenu(null); } }; window.addEventListener("mousedown", handleClick); return () => window.removeEventListener("mousedown", handleClick); }, [contextMenu]); useEffect(() => { if (!isAuthenticated) { setServerAssets([]); setServerNotice(null); setIsLoading(false); return; } let cancelled = false; setIsLoading(true); setServerNotice("正在同步服务器资产"); assetClient .list() .then((items) => { if (cancelled) return; setServerAssets(items); setServerNotice(items.length ? "服务器资产已同步" : "暂无资产"); }) .catch((error) => { if (cancelled) return; setServerAssets([]); setServerNotice(error instanceof Error && error.message ? error.message : "资产服务器暂时不可用"); }) .finally(() => { if (!cancelled) setIsLoading(false); }); return () => { cancelled = true; }; }, [isAuthenticated]); const allAssets = useMemo(() => { return serverAssets.map(mapServerAsset); }, [serverAssets]); const visibleAssets = useMemo(() => { const normalizedQuery = debouncedQuery.trim().toLowerCase(); return allAssets.filter((asset) => { const typeMatched = activeType === "all" || asset.type === activeType; const queryMatched = !normalizedQuery || asset.name.toLowerCase().includes(normalizedQuery) || asset.description.toLowerCase().includes(normalizedQuery) || asset.tags.some((tag) => tag.toLowerCase().includes(normalizedQuery)); return typeMatched && queryMatched; }); }, [activeType, allAssets, debouncedQuery]); const completeCount = allAssets.filter((asset) => asset.status === "ready").length; const latestAsset = allAssets[0]; useEffect(() => { if (!previewAsset) return undefined; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setPreviewAsset(null); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [previewAsset]); if (!isAuthenticated) { return ( 请登录 登录后可查看与管理你的云端资产库。 登录 / 注册 ); } return ( {typeTabs.map((tab) => ( setActiveType(tab.key)} > {tab.icon} {tab.label} ))} setQuery(e.target.value)} placeholder="搜索资产..." /> uploadInputRef.current?.click()} disabled={isUploading}> {isUploading ? : } {isUploading ? "上传中..." : "添加"} void handleUploadFiles(e.target.files)} /> {visibleAssets.length ? ( {visibleAssets.map((asset) => ( setPreviewAsset(asset)} onContextMenu={(e) => handleContextMenu(e, asset)} aria-label={`预览素材 ${asset.name}`} > {asset.imageUrl ? : null} {asset.name} {statusLabel[asset.status]} {asset.description} {asset.tags.slice(0, 2).map((tag) => ( {tag} ))} { e.stopPropagation(); void handleDeleteAsset(asset); }} aria-label={`删除 ${asset.name}`} > ))} ) : isLoading ? ( ) : ( } title="暂无资产" description="在工作台生成图片或视频后,素材会自动同步到这里。" /> )} {previewAsset ? ( setPreviewAsset(null)} /> event.stopPropagation()}> {previewAsset.name} {previewAsset.description} setPreviewAsset(null)}> {getAssetPreviewUrl(previewAsset) ? ( isVideoPreview(previewAsset) ? ( ) : ( ) ) : ( 暂无可预览素材 )} ) : null} {contextMenu ? ( void handleDeleteAsset()}> 删除资产 ) : null} ); } export default AssetsPage;
登录后可查看与管理你的云端资产库。
{asset.description}