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

434 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ClockCircleOutlined,
CloseOutlined,
DatabaseOutlined,
DeleteOutlined,
FileImageOutlined,
LoadingOutlined,
LoginOutlined,
PlusOutlined,
SearchOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, 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: <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 [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);
}
};
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<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${isUploadDragging ? " is-dragging" : ""}`}
onClick={() => uploadInputRef.current?.click()}
onDragOver={handleUploadDragOver}
onDragLeave={handleUploadDragLeave}
onDrop={handleUploadDrop}
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}
</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>
</div>
</button>
<button
type="button"
className="asset-card__delete"
title="删除素材"
onClick={(e) => { e.stopPropagation(); void handleDeleteAsset(asset); }}
aria-label={`删除 ${asset.name}`}
>
<DeleteOutlined />
</button>
</div>
))}
</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;