Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
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 { 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 () => {
|
||||
if (!contextMenu) return;
|
||||
const { asset } = contextMenu;
|
||||
setContextMenu(null);
|
||||
try {
|
||||
await assetClient.delete(asset.id);
|
||||
setServerAssets((prev) => prev.filter((a) => a.id !== asset.id));
|
||||
setServerNotice(`已删除 ${asset.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" 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) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
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>
|
||||
))}
|
||||
</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;
|
||||
Reference in New Issue
Block a user