Files
omniai-web/src/features/assets/AssetsPage.tsx
T
stringadmin f5a75074a4 feat: 邮箱注册验证 + 9项功能修复与优化
【认证系统】
- 新增邮箱验证码注册/登录流程 (sendEmailCode / verifyEmail / forgotPassword / resetPassword)
- register-email 现在需要验证码
- 服务端新增 email_verification_codes 表 + patch-email-verification.js
- App.tsx 登录后 emailVerified 检查提醒
- keyServerClient token 显式传递修复 401 错误

【电商模块】
- 自动推进: 策划完成后自动生成分镜图/视频
- 模特图选项 (性别/年龄/种族/体型/场景) 注入 AI 提示词
- 任务持久化指纹修复 (图片数量替代 blob URL)
- 新增「视频换装」入口 (happyhorse-1.0-video-edit)

【剧本评分】
- 新增 .docx/.doc Word 文档支持 (ZIP解压+XML提取)
- 历史记录支持点击查看/恢复评测结果

【画布】
- ReactFlow 节点禁止内置拖拽避免冲突
- 连接线拖拽弹窗优化 (预览线不消失, 弹窗跟踪鼠标)

【页面修复】
- 首页轮播图改为 aspect-ratio: 16/9 解决尺寸问题
- 资产库新增悬停删除按钮
- scriptEvalClient 改用服务端 /api/ai/chat 端点
- TokenUsagePage 未登录跳过 API 调用
2026-06-03 20:19:07 +08:00

414 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 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 (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" 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}
</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;