Files
omniai-web/src/features/assets/AssetsPage.tsx
T

415 lines
16 KiB
TypeScript
Raw Normal View History

2026-06-02 12:38:01 +08:00
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";
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;
}
const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: JSX.Element | null }> = [
{ 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);
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;
2026-06-02 12:38:01 +08:00
setContextMenu(null);
try {
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>
<button type="button" className="studio-generate-btn studio-generate-btn--compact" onClick={() => uploadInputRef.current?.click()} disabled={isUploading}>
{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) => (
<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>
<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>
</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;