2026-06-02 12:38:01 +08:00
|
|
|
|
import {
|
|
|
|
|
|
ClockCircleOutlined,
|
|
|
|
|
|
CloseOutlined,
|
|
|
|
|
|
DatabaseOutlined,
|
|
|
|
|
|
DeleteOutlined,
|
|
|
|
|
|
FileImageOutlined,
|
|
|
|
|
|
LoadingOutlined,
|
|
|
|
|
|
LoginOutlined,
|
|
|
|
|
|
PlusOutlined,
|
|
|
|
|
|
SearchOutlined,
|
|
|
|
|
|
UserOutlined,
|
|
|
|
|
|
} from "@ant-design/icons";
|
2026-06-09 11:34:56 +08:00
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactElement } from "react";
|
2026-06-05 17:19:38 +08:00
|
|
|
|
import "../../styles/pages/assets.css";
|
2026-06-02 12:38:01 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-09 11:34:56 +08:00
|
|
|
|
const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: ReactElement | null }> = [
|
2026-06-02 12:38:01 +08:00
|
|
|
|
{ key: "all", label: "全部", icon: null },
|
|
|
|
|
|
{ key: "character", label: "人物", icon: <UserOutlined /> },
|
|
|
|
|
|
{ key: "scene", label: "场景", icon: <FileImageOutlined /> },
|
|
|
|
|
|
{ key: "prop", label: "物品", icon: <DatabaseOutlined /> },
|
|
|
|
|
|
{ key: "video", label: "视频", icon: <FileImageOutlined /> },
|
|
|
|
|
|
{ key: "image", label: "图片", icon: <FileImageOutlined /> },
|
|
|
|
|
|
{ key: "asset", label: "素材", icon: <DatabaseOutlined /> },
|
|
|
|
|
|
{ key: "other", label: "其他", icon: <DatabaseOutlined /> },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const statusLabel: Record<WebAssetItem["status"], string> = {
|
|
|
|
|
|
ready: "已完成",
|
|
|
|
|
|
draft: "草稿",
|
|
|
|
|
|
reviewing: "复核中",
|
|
|
|
|
|
pending: "同步中",
|
|
|
|
|
|
failed: "失败",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const statusBadgeClass: Record<WebAssetItem["status"], string> = {
|
|
|
|
|
|
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<AssetTypeFilter>("all");
|
|
|
|
|
|
const [query, setQuery] = useState("");
|
|
|
|
|
|
const debouncedQuery = useDebounce(query, 300);
|
|
|
|
|
|
const [serverAssets, setServerAssets] = useState<ServerAssetItem[]>([]);
|
|
|
|
|
|
const [serverNotice, setServerNotice] = useState<string | null>(null);
|
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
|
|
|
const [previewAsset, setPreviewAsset] = useState<LibraryAssetItem | null>(null);
|
|
|
|
|
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; asset: LibraryAssetItem } | null>(null);
|
|
|
|
|
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
2026-06-08 21:30:48 +08:00
|
|
|
|
const [isUploadDragging, setIsUploadDragging] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
const handleUploadDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsUploadDragging(true); };
|
|
|
|
|
|
const handleUploadDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsUploadDragging(false); };
|
|
|
|
|
|
const handleUploadDrop = (e: DragEvent) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
setIsUploadDragging(false);
|
|
|
|
|
|
if (e.dataTransfer.files.length) {
|
|
|
|
|
|
void handleUploadFiles(e.dataTransfer.files);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-06-02 12:38:01 +08:00
|
|
|
|
|
|
|
|
|
|
const handleContextMenu = useCallback((e: React.MouseEvent, asset: LibraryAssetItem) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
setContextMenu({ x: e.clientX, y: e.clientY, asset });
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-06-03 20:19:07 +08:00
|
|
|
|
const handleDeleteAsset = useCallback(async (asset?: LibraryAssetItem) => {
|
|
|
|
|
|
const target = asset || contextMenu?.asset;
|
|
|
|
|
|
if (!target) return;
|
2026-06-02 12:38:01 +08:00
|
|
|
|
setContextMenu(null);
|
|
|
|
|
|
try {
|
2026-06-03 20:19:07 +08:00
|
|
|
|
await assetClient.delete(target.id);
|
|
|
|
|
|
setServerAssets((prev) => prev.filter((a) => a.id !== target.id));
|
|
|
|
|
|
setServerNotice(`已删除 ${target.name}`);
|
2026-06-02 12:38:01 +08:00
|
|
|
|
} 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<string>((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<LibraryAssetItem[]>(() => {
|
|
|
|
|
|
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 (
|
|
|
|
|
|
<WorkspacePageShell title="资产库" fullWidth className="assets-page page-motion">
|
|
|
|
|
|
<div className="assets-centered-page assets-centered-page--empty">
|
|
|
|
|
|
<div className="assets-login-required" role="status">
|
|
|
|
|
|
<div className="assets-login-required__icon">
|
|
|
|
|
|
<LoginOutlined />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<strong>请登录</strong>
|
|
|
|
|
|
<p>登录后可查看与管理你的云端资产库。</p>
|
|
|
|
|
|
<button type="button" className="studio-generate-btn" onClick={onOpenLogin}>
|
|
|
|
|
|
<LoginOutlined />
|
|
|
|
|
|
登录 / 注册
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</WorkspacePageShell>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<WorkspacePageShell title="资产库" fullWidth className="assets-page page-motion">
|
|
|
|
|
|
<div className="assets-centered-page">
|
|
|
|
|
|
<div className="assets-centered-page__head">
|
|
|
|
|
|
<div className="studio-tabs">
|
|
|
|
|
|
{typeTabs.map((tab) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={tab.key}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={activeType === tab.key ? "is-active" : ""}
|
|
|
|
|
|
onClick={() => setActiveType(tab.key)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{tab.icon}
|
|
|
|
|
|
{tab.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<label className="asset-search">
|
|
|
|
|
|
<SearchOutlined />
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={query}
|
|
|
|
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
|
|
|
|
placeholder="搜索资产..."
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
2026-06-08 21:30:48 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`studio-generate-btn studio-generate-btn--compact${isUploadDragging ? " is-dragging" : ""}`}
|
|
|
|
|
|
onClick={() => uploadInputRef.current?.click()}
|
|
|
|
|
|
onDragOver={handleUploadDragOver}
|
|
|
|
|
|
onDragLeave={handleUploadDragLeave}
|
|
|
|
|
|
onDrop={handleUploadDrop}
|
|
|
|
|
|
disabled={isUploading}
|
|
|
|
|
|
>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
{isUploading ? <LoadingOutlined /> : <PlusOutlined />}
|
|
|
|
|
|
{isUploading ? "上传中..." : "添加"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={uploadInputRef}
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept="image/*,video/*"
|
|
|
|
|
|
multiple
|
|
|
|
|
|
style={{ display: "none" }}
|
|
|
|
|
|
onChange={(e) => void handleUploadFiles(e.target.files)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="assets-centered-page__body">
|
|
|
|
|
|
{visibleAssets.length ? (
|
|
|
|
|
|
<div className="asset-grid asset-grid--desktop motion-stagger">
|
|
|
|
|
|
{visibleAssets.map((asset) => (
|
2026-06-03 20:19:07 +08:00
|
|
|
|
<div key={asset.id} className="asset-card-wrapper">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="asset-card asset-card--desktop"
|
|
|
|
|
|
onClick={() => setPreviewAsset(asset)}
|
|
|
|
|
|
onContextMenu={(e) => handleContextMenu(e, asset)}
|
|
|
|
|
|
aria-label={`预览素材 ${asset.name}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className={`asset-card__thumb ${asset.thumbClass}`}>
|
|
|
|
|
|
{asset.imageUrl ? <OptimizedImage src={asset.imageUrl} alt={asset.name} /> : null}
|
2026-06-02 12:38:01 +08:00
|
|
|
|
</div>
|
2026-06-03 20:19:07 +08:00
|
|
|
|
<div className="asset-card__body">
|
|
|
|
|
|
<div className="asset-card__head">
|
|
|
|
|
|
<strong>{asset.name}</strong>
|
|
|
|
|
|
<span className={`studio-status-bar__badge ${statusBadgeClass[asset.status]}`}>
|
|
|
|
|
|
{statusLabel[asset.status]}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="asset-card__desc">{asset.description}</p>
|
|
|
|
|
|
<div className="asset-card__tags">
|
|
|
|
|
|
{asset.tags.slice(0, 2).map((tag) => (
|
|
|
|
|
|
<span key={tag}>{tag}</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
</div>
|
2026-06-03 20:19:07 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="asset-card__delete"
|
|
|
|
|
|
title="删除素材"
|
|
|
|
|
|
onClick={(e) => { e.stopPropagation(); void handleDeleteAsset(asset); }}
|
|
|
|
|
|
aria-label={`删除 ${asset.name}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<DeleteOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : isLoading ? (
|
|
|
|
|
|
<SkeletonList count={6} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<EmptyState
|
|
|
|
|
|
icon={<FileImageOutlined style={{ fontSize: 48 }} />}
|
|
|
|
|
|
title="暂无资产"
|
|
|
|
|
|
description="在工作台生成图片或视频后,素材会自动同步到这里。"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<footer className="assets-centered-page__footer">
|
|
|
|
|
|
<span className="studio-status-bar__badge studio-status-bar__badge--success">
|
|
|
|
|
|
{isLoading ? "同步中" : "就绪"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="studio-status-bar__text">
|
|
|
|
|
|
已加载 {allAssets.length} 个资产,{completeCount} 个已完成
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{serverNotice ? <span className="studio-status-bar__meta">{serverNotice}</span> : null}
|
|
|
|
|
|
{latestAsset ? (
|
|
|
|
|
|
<span className="studio-status-bar__meta">
|
|
|
|
|
|
<ClockCircleOutlined /> 最近更新 {latestAsset.updatedAt}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</footer>
|
|
|
|
|
|
|
|
|
|
|
|
{previewAsset ? (
|
|
|
|
|
|
<div className="asset-preview-modal" role="dialog" aria-modal="true" aria-label={`预览素材 ${previewAsset.name}`}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="asset-preview-modal__backdrop"
|
|
|
|
|
|
aria-label="关闭预览"
|
|
|
|
|
|
onClick={() => setPreviewAsset(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<section className="asset-preview-modal__panel" onClick={(event) => event.stopPropagation()}>
|
|
|
|
|
|
<header className="asset-preview-modal__head">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>{previewAsset.name}</strong>
|
|
|
|
|
|
<span>{previewAsset.description}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="button" className="asset-preview-modal__close" aria-label="关闭预览" onClick={() => setPreviewAsset(null)}>
|
|
|
|
|
|
<CloseOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<div className="asset-preview-modal__body">
|
|
|
|
|
|
{getAssetPreviewUrl(previewAsset) ? (
|
|
|
|
|
|
isVideoPreview(previewAsset) ? (
|
|
|
|
|
|
<video src={getAssetPreviewUrl(previewAsset) || undefined} controls playsInline />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<img src={getAssetPreviewUrl(previewAsset) || undefined} alt={previewAsset.name} />
|
|
|
|
|
|
)
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="asset-preview-modal__empty">
|
|
|
|
|
|
<FileImageOutlined />
|
|
|
|
|
|
<strong>暂无可预览素材</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<footer className="asset-preview-modal__meta">
|
|
|
|
|
|
<span>{statusLabel[previewAsset.status]}</span>
|
|
|
|
|
|
<span>{previewAsset.ratio}</span>
|
|
|
|
|
|
<span>{previewAsset.project}</span>
|
|
|
|
|
|
<span>{previewAsset.updatedAt}</span>
|
|
|
|
|
|
</footer>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{contextMenu ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={contextMenuRef}
|
|
|
|
|
|
className="asset-context-menu"
|
|
|
|
|
|
style={{ top: contextMenu.y, left: contextMenu.x }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button type="button" onClick={() => void handleDeleteAsset()}>
|
|
|
|
|
|
<DeleteOutlined />
|
|
|
|
|
|
删除资产
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</WorkspacePageShell>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default AssetsPage;
|