feat(core): Wave 3 batch2 — 资产库详情/上传/批量 + AI工具模特库/gen-card/pl-modal

资产库(P0,sub-agent):多选+吸底 bulk-bar、资产详情弹窗(立绘+三视图+标签/属性+商品媒体画廊)、
  上传 Drawer→居中 Modal(类型分段+按类型字段+拖拽预览)、缺三视图徽标(复用现成类)
AI图片工具(P0,sub-agent):接入现成 ActorLibrary 模特库弹窗、gen-card 三件套(再生成/就地反馈/
  已采用绿角标,采用反馈去全局弹窗改就地)、平台套图恢复多选+分组、商品库 pl-modal 选择器(restraint重写)

验证: tsc 0 · build 0 · 走查 资产详情/上传弹窗开关ESC + 模特库/pl-modal 打开 全过 · 0 console error

⚠️ 真实数据缺(未造假):资产「移动到」改分类需 api.updateAsset 支持 category 字段;
  详情三视图版本历史 V1 是 mock 无真实源;平台无 platform 字段→前端组织分批。均移交监督。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 05:08:21 +08:00
co-authored by Claude Opus 4.8
parent 0083918207
commit 38f56cb037
5 changed files with 1395 additions and 164 deletions
+436 -47
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent, ReactNode } from "react";
import { createPortal } from "react-dom";
import { Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Shield, Trash2, Upload, X } from "lucide-react";
import { api } from "../api";
import { SkeletonGrid } from "../components/loading";
import type { Asset } from "../types";
import { ConfirmModal, Drawer, MediaLightbox } from "../components/overlays";
import { ConfirmModal, MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import { Pager } from "../components/pager";
const LIB_PAGE_SIZE = 10;
@@ -24,6 +26,14 @@ function inferAssetType(file: File): string {
return "document";
}
// 资产类型 → 上传 Modal 接受的文件 MIME(类型决定能选什么文件 + 动态字段)
const UPLOAD_KINDS: Array<{ value: string; label: string; accept: string; hint: string }> = [
{ value: "image", label: "图片", accept: "image/*", hint: "PNG / JPG / WEBP" },
{ value: "video", label: "视频", accept: "video/*", hint: "MP4 / MOV · 9:16 竖屏优先" },
{ value: "audio", label: "音频", accept: "audio/*", hint: "MP3 / WAV · 配音 / BGM" },
{ value: "subtitle", label: "字幕", accept: ".srt,.vtt,.ass", hint: "SRT / VTT / ASS" }
];
type LibTab = "people" | "scenes" | "products" | "finals" | "uploads" | "unclassified";
const LIB_TABS: Array<{ key: LibTab; label: string }> = [
@@ -44,38 +54,373 @@ const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
{ key: "source", label: "来源", tabs: ["people", "scenes", "products", "uploads"] }
];
function assetTab(asset: Asset): LibTab {
switch (asset.category) {
case "person": return "people";
case "scene": return "scenes";
case "product_image": return "products";
case "final_video": return "finals";
case "upload": return "uploads";
default: return asset.asset_type === "video" ? "finals" : "unclassified";
// metadata 里可能用的中文属性键(真实存在才渲染,缺则不显;属性区不造假)
const META_PROP_LABELS: Record<string, string> = {
gender: "性别", age: "年龄段", role: "角色", sceneType: "场景类型", scene_type: "场景类型",
duration: "时长", product: "关联商品", project: "关联项目", ratio: "画幅", resolution: "分辨率",
tone: "风格", style: "风格"
};
function metaStr(v: unknown): string {
if (v == null) return "";
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return "";
}
// 人物资产是否缺三视图:metadata.tri_view / triview / has_triview 任一为真即算有;无标记一律按「缺」处理
function personMissingTri(asset: Asset): boolean {
if (asset.category !== "person") return false;
const m = asset.metadata || {};
const has = m.tri_view ?? m.triview ?? m.has_triview ?? m.three_view ?? m.tri_views;
if (has === true) return false;
if (Array.isArray(has) && has.length > 0) return false;
if (typeof has === "string" && has.trim()) return false;
// 上传来源 + 无三视图标记 → 判缺(AI 生成默认带三视图,不打扰)
return asset.source === "upload";
}
// ─────────────────────────────────────────────────────────────────────────
// 资产详情弹窗 · 立绘大图 + 三视图/版本历史(人物)+ 简介/标签/属性 + 媒体画廊(商品)
// 用真实 Asset 字段渲染;缺字段留「-」或整段降级,不造 mock。
// 复用 useOverlayTransition / useBodyScrollLock(不改 overlays.tsx),portal 到 body 盖住侧栏。
// ─────────────────────────────────────────────────────────────────────────
function AssetDetailModal({ asset, close, onZoom }: {
asset: Asset | null;
close: () => void;
onZoom: (src: string, kind: "image" | "video" | "audio", name: string) => void;
}) {
useBodyScrollLock(!!asset);
const { mounted, show } = useOverlayTransition(!!asset, close);
// 多图资产:当前选中的主图索引(缩略图切换)
const [activeIdx, setActiveIdx] = useState(0);
useEffect(() => { setActiveIdx(0); }, [asset?.id]);
if (!mounted || !asset) return null;
const files = asset.files || [];
const isVideo = asset.asset_type === "video";
const isAudio = asset.asset_type === "audio";
const isPerson = asset.category === "person";
const isProduct = asset.category === "product_image";
const kindLabel = KIND_LABELS[asset.asset_type] || asset.asset_type || "资产";
const catLabel = CATEGORY_LABELS[asset.category || ""] || asset.category || "-";
const srcLabel = SOURCE_LABELS[asset.source || ""] || asset.source || "-";
const createdAt = (asset.created_at || "").slice(0, 16).replace("T", " ") || "-";
const previewKind: "image" | "video" | "audio" = isVideo ? "video" : isAudio ? "audio" : "image";
// 主图候选:优先 is_primary,其次首张;activeIdx 控制当前看哪张
const ordered = [...files].sort((a, b) => Number(b.is_primary) - Number(a.is_primary));
const cur = ordered[activeIdx] || ordered[0];
const leadSrc = cur?.preview_url || "";
// 简介:asset.description 真有才显,否则降级提示
const intro = (asset.description || "").trim();
// 标签:metadata.tags(数组)真有才渲染
const rawTags = (asset.metadata?.tags as unknown);
const tags = Array.isArray(rawTags) ? rawTags.map(metaStr).filter(Boolean) : [];
// 属性表:从 metadata 真实键派生 + 资产固有字段;空值留「-」
const metaProps: Array<{ k: string; v: string }> = [];
Object.entries(META_PROP_LABELS).forEach(([key, label]) => {
const v = metaStr(asset.metadata?.[key]);
if (v && !metaProps.some((p) => p.k === label)) metaProps.push({ k: label, v });
});
const props: Array<{ k: string; v: string }> = [
{ k: "分类", v: catLabel },
{ k: "类型", v: kindLabel },
{ k: "来源", v: srcLabel },
...metaProps,
{ k: "文件数", v: files.length ? String(files.length) : "-" },
{ k: "入库时间", v: createdAt }
];
// 人物审核盾:仅真人有 review_status(processing/active 绿/failed 红)
const review = asset.review_status;
function renderLead(): ReactNode {
if (leadSrc && isVideo) {
return <video src={leadSrc} muted playsInline preload="metadata" />;
}
if (isAudio) {
return <span className="adx-audio-ph" aria-hidden="true"><Music /><span className="mono">AUDIO</span></span>;
}
if (leadSrc) return <img src={leadSrc} alt={asset!.name} />;
return <span className="ph-frame">{isPerson ? "立绘 / 主图" : kindLabel}</span>;
}
return createPortal(
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
<div className={`asset-detail-modal with-corners${isProduct ? " product-mode" : ""}`} onClick={(event) => event.stopPropagation()}>
<span className="corner-tr" /><span className="corner-bl" />
<div className="adx-h">
<h2>{asset.name || "资产详情"}</h2>
<span className="adx-kind">/ {kindLabel}</span>
{review === "active" && <span className="pill ok adx-shield"><Shield size={11} /> 已审核</span>}
{review === "processing" && <span className="pill info adx-shield">审核中</span>}
{review === "failed" && <span className="pill err adx-shield">审核未过</span>}
<button className="x adx-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button>
</div>
<div className="adx-body">
<div className="adx-grid">
{/* ── 左:立绘 / 主图 + 缩略图切换 ── */}
<div className="adx-lead">
<div className="adx-lead-wrap">
<div className="placeholder adx-lead-img" role={leadSrc ? "button" : undefined} tabIndex={leadSrc ? 0 : undefined} onClick={leadSrc ? () => onZoom(leadSrc, previewKind, asset!.name) : undefined} title={leadSrc ? "查看大图" : undefined}>
{renderLead()}
</div>
{leadSrc && (
<button className="adx-zoom-btn" type="button" aria-label="查看大图" title="查看大图" onClick={() => onZoom(leadSrc, previewKind, asset!.name)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5" /></svg>
</button>
)}
</div>
{ordered.length > 1 && (
<div className="adx-thumbs">
{ordered.map((f, i) => (
<button key={f.id} type="button" className={`adx-thumb${i === activeIdx ? " active" : ""}`} onClick={() => setActiveIdx(i)} title={f.is_primary ? "主图" : `第 ${i + 1} 张`}>
{f.preview_url ? <img src={f.preview_url} alt="" /> : <span className="ph-frame">{i + 1}</span>}
</button>
))}
</div>
)}
</div>
{/* ── 右:三视图(人物)+ 简介 + 标签 + 属性 ── */}
<div className="adx-right">
{isPerson && (
<div className="adx-section">
<div className="adx-section-h">
<span className="ic"><LayoutGrid size={14} /></span>
<span className="t">三视图</span>
<span className="adx-ratio-chip">16:9</span>
</div>
{personMissingTri(asset) ? (
<div className="adx-tri-missing">
<Info size={14} />
<span>手动上传的人物未生成 <b>正 / 侧 / 背</b> 三视图,建议前往图片生成补齐以保证多角度一致性。</span>
</div>
) : ordered.length > 1 ? (
<div className="adx-tri-row">
{ordered.slice(0, 3).map((f, i) => (
<button key={f.id} type="button" className="placeholder adx-tri-cell" onClick={f.preview_url ? () => onZoom(f.preview_url, "image", asset!.name) : undefined}>
{f.preview_url ? <img src={f.preview_url} alt="" /> : <span className="ph-frame">{["正", "侧", "背"][i] || i + 1}</span>}
</button>
))}
</div>
) : (
<div className="adx-tri-row">
<div className="placeholder adx-tri-cell single">
{leadSrc ? <img src={leadSrc} alt="" /> : <span className="ph-frame">正 / 侧 / 背 · 三视图</span>}
</div>
</div>
)}
</div>
)}
<div className="adx-section">
<div className="adx-section-h">
<span className="ic"><Info size={14} /></span>
<span className="t">简介</span>
</div>
<p className="adx-intro">{intro || <span className="adx-muted">// 暂无简介</span>}</p>
{tags.length > 0 && (
<div className="adx-tags">
{tags.map((t) => <span className="adx-tag" key={t}>{t}</span>)}
</div>
)}
</div>
<div className="adx-section">
<div className="adx-section-h">
<span className="ic"><LayoutGrid size={14} /></span>
<span className="t">属性</span>
</div>
<div className="adx-props">
{props.map((p) => (
<div className="adx-prop-row" key={p.k}><span className="k">{p.k}</span><span className="v">{p.v}</span></div>
))}
</div>
</div>
{/* 商品图:媒体画廊(用资产自身的多文件;无更多关联数据时仅列已有文件,不编造 SKU) */}
{isProduct && files.length > 0 && (
<div className="adx-section">
<div className="adx-section-h">
<span className="ic"><Images size={14} /></span>
<span className="t">商品媒体</span>
<span className="adx-ratio-chip">{files.length} 张</span>
</div>
<div className="adx-gallery">
{files.map((f, i) => (
<button key={f.id} type="button" className="adx-media-card" onClick={f.preview_url ? () => onZoom(f.preview_url, "image", asset!.name) : undefined}>
<div className="adx-media-img">
{f.preview_url ? <img src={f.preview_url} alt="" /> : <span className="ph-frame">第 {i + 1} 张</span>}
<span className="asset-badge">{f.is_primary ? "主图" : `#${i + 1}`}</span>
</div>
</button>
))}
</div>
</div>
)}
</div>
</div>
</div>
<div className="adx-f">
{leadSrc && (
<a className="btn adx-foot-dl" href={leadSrc} download={asset.name || undefined} target="_blank" rel="noreferrer">
<Download size={13} /> 下载
</a>
)}
<span className="adx-foot-meta mono">// {catLabel} · {srcLabel} · {createdAt}</span>
<button className="btn btn-primary" type="button" onClick={close}>完成</button>
</div>
</div>
</div>,
document.body
);
}
// ─────────────────────────────────────────────────────────────────────────
// 上传资产 · 居中 Modal · 资产类型下拉 + 按类型动态 accept + 拖拽预览
// 复用 useOverlayTransition / useBodyScrollLock,portal 到 body。
// ─────────────────────────────────────────────────────────────────────────
function UploadModal({ open, close, onSubmit }: {
open: boolean;
close: () => void;
onSubmit: (file: File, name: string, assetType: string) => Promise<void>;
}) {
useBodyScrollLock(open);
const { mounted, show } = useOverlayTransition(open, close);
const [kind, setKind] = useState("image");
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState("");
const [uploading, setUploading] = useState(false);
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const previewUrl = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
useEffect(() => () => { if (previewUrl) URL.revokeObjectURL(previewUrl); }, [previewUrl]);
// 关闭后清空,下次打开是干净状态
useEffect(() => { if (!open) { setFile(null); setName(""); setKind("image"); setDragging(false); } }, [open]);
const curKind = UPLOAD_KINDS.find((k) => k.value === kind) || UPLOAD_KINDS[0];
const isImageKind = kind === "image";
const isVideoKind = kind === "video";
const isAudioKind = kind === "audio";
function pick(f: File | null) {
if (!f) return;
setFile(f);
if (!name) setName(f.name.replace(/\.[^.]+$/, ""));
}
async function submit(event: FormEvent) {
event.preventDefault();
if (!file || uploading) return;
setUploading(true);
try {
// 资产类型以 MIME 推断为准,下拉只决定可选文件 + 字段;不一致时仍按真实文件类型落库
await onSubmit(file, name || file.name, inferAssetType(file));
close();
} finally {
setUploading(false);
}
}
if (!mounted) return null;
return createPortal(
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
<div className="modal upload-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h"><div className="ic-m"><Upload size={16} /></div><div className="ti">上传资产<span>// UPLOAD</span></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
<form className="upload-modal-form" onSubmit={submit}>
<div className="modal-b">
<div className="field">
<label className="field-label">资产类型</label>
<div className="upload-kind-seg" role="tablist">
{UPLOAD_KINDS.map((k) => (
<button key={k.value} type="button" className={`uk-seg${kind === k.value ? " active" : ""}`} onClick={() => { setKind(k.value); setFile(null); }}>{k.label}</button>
))}
</div>
</div>
<div className="field">
<label className="field-label">文件</label>
{file ? (
<div className={`upload-preview${isVideoKind ? " video" : ""}`}>
{isImageKind && <img src={previewUrl} alt="预览" />}
{isVideoKind && <video src={previewUrl} muted playsInline />}
{(isAudioKind || (!isImageKind && !isVideoKind)) && (
<span className="upload-file-chip"><ImageIcon size={18} /><span className="mono">{(file.name.split(".").pop() || "FILE").toUpperCase()}</span></span>
)}
<button className="preview-x" type="button" aria-label="移除" onClick={() => setFile(null)}><X size={12} /></button>
</div>
) : (
<div
className={`upload-zone${dragging ? " dragover" : ""}`}
role="button"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); inputRef.current?.click(); } }}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => { e.preventDefault(); setDragging(false); pick(e.dataTransfer.files?.[0] || null); }}
>
<span className="uz-ic"><Upload size={18} /></span>
<span>拖拽文件到此,或点击选择</span>
<span className="uz-hint mono">{curKind.hint}</span>
</div>
)}
<input ref={inputRef} type="file" accept={curKind.accept} hidden onChange={(e) => pick(e.target.files?.[0] || null)} />
</div>
<div className="field">
<label className="field-label">资产名称</label>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="留空则用文件名" />
</div>
</div>
<div className="modal-f">
<span className="upload-foot-meta mono">{file ? <><span className="accent">{file.name}</span> · {(file.size / 1024 / 1024).toFixed(2)} MB</> : "// 选择一个文件"}</span>
<button className="btn" type="button" onClick={close}>取消</button>
<button className="btn btn-primary" type="submit" disabled={!file || uploading}>{uploading ? "上传中…" : "上传资产"}</button>
</div>
</form>
</div>
</div>,
document.body
);
}
export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormData) => Promise<unknown> | void; onDelete?: (id: string) => Promise<unknown> | void }) {
const [tab, setTab] = useState<LibTab>("people");
const [query, setQuery] = useState("");
const [drawer, setDrawer] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState("");
const [uploading, setUploading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const [openChip, setOpenChip] = useState("");
const [srcFilter, setSrcFilter] = useState("");
const [kindFilter, setKindFilter] = useState("");
const [sortDesc, setSortDesc] = useState(true);
// 编辑模式 + 元数据筛选(性别/年龄/角色/场景类型/关联/时长 走 asset.metadata,真实存在才有可选项)
// 编辑模式 + 多选 + 元数据筛选(性别/年龄/角色/场景类型/关联/时长 走 asset.metadata,真实存在才有可选项)
const [editMode, setEditMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [metaFilter, setMetaFilter] = useState<Record<string, string>>({});
const [confirmId, setConfirmId] = useState<string | null>(null);
// 资产预览灯箱(图片放大 / 视频播放 / 音频试听)
// 删除确认:单删传 [id],批删传选中数组
const [confirmIds, setConfirmIds] = useState<string[] | null>(null);
// 乐观隐藏:删除中的卡片立即从网格移除
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
// 资产详情弹窗 + 灯箱(详情里点大图再放大)
const [detail, setDetail] = useState<Asset | null>(null);
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video" | "audio"; name: string } | null>(null);
useEffect(() => {
document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode");
}, [editMode]);
const toggleSelect = (id: string) => setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
const [items, setItems] = useState<Asset[]>([]);
@@ -91,8 +436,8 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// 切 tab / 改筛选 / 改排序回到第 1 页
useEffect(() => { setPage(1); }, [tab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc]);
// 切 tab 时清空与该 tab 无关的筛选
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); }, [tab]);
// 切 tab 时清空与该 tab 无关的筛选 + 退出多选(避免跨 tab 误删)
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); setSelected(new Set()); }, [tab]);
// 当前 tab 的 metadata 筛选键(来源/类型走独立字段,不算 metadata)
const metaKeys = LIB_CHIPS.filter((c) => c.tabs.includes(tab) && c.key !== "source" && c.key !== "kind").map((c) => c.key);
@@ -132,6 +477,8 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
const hasFilter = Boolean(debouncedQuery || srcFilter || kindFilter || Object.values(metaFilter).some(Boolean));
const totalPages = Math.max(1, Math.ceil(total / LIB_PAGE_SIZE));
const curPage = Math.min(page, totalPages);
// 乐观隐藏后真正渲染的列表
const shown = items.filter((a) => !deletingIds.has(a.id));
useEffect(() => {
if (!openChip) return;
@@ -142,24 +489,29 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
return () => document.removeEventListener("click", close);
}, [openChip]);
async function submit(event: FormEvent) {
event.preventDefault();
if (!file || uploading) return;
// 上传:拼 FormData 走父级 onUpload,完成后刷新列表/计数/筛选项
async function doUpload(file: File, name: string, assetType: string) {
const formData = new FormData();
formData.append("file", file);
formData.append("name", name || file.name);
formData.append("asset_type", inferAssetType(file));
formData.append("asset_type", assetType);
formData.append("category", "upload");
// 上传 + 列表刷新要数秒,按钮给「上传中…」反馈,防止用户重复点
setUploading(true);
await onUpload(formData);
setReloadFlag((f) => f + 1);
}
// 删除(单 / 批):乐观隐藏 → 并发删 → 刷新计数/列表
async function doDelete() {
const ids = confirmIds || [];
setConfirmIds(null);
if (!ids.length || !onDelete) return;
setDeletingIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.add(id)); return next; });
setSelected(new Set());
try {
await onUpload(formData);
setDrawer(false);
setFile(null);
setName("");
setReloadFlag((f) => f + 1); // 上传后刷新列表/计数/筛选项
await Promise.all(ids.map((id) => onDelete(id)));
setReloadFlag((f) => f + 1);
} finally {
setUploading(false);
setDeletingIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.delete(id)); return next; });
}
}
@@ -171,11 +523,11 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
<div className="sub"><span className="mono">// 跨项目复用 · <span id="sub-people">{counts.people}</span> 人 · <span id="sub-scenes">{counts.scenes}</span> 景 · <span id="sub-products">{counts.products}</span> 商 · <span id="sub-finals">{counts.finals}</span> 片</span></div>
</div>
<div className="actions">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => setEditMode((v) => !v)}>
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
<span className="lib-manage-label">{editMode ? "完成" : "管理资产"}</span>
</button>
<button className="btn btn-primary" type="button" id="open-upload-btn" onClick={() => setDrawer(true)}>
<button className="btn btn-primary" type="button" id="open-upload-btn" onClick={() => setUploadOpen(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
上传资产
</button>
@@ -273,29 +625,46 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
</div>
</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{items.length}</span> / {total} 个资产{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{shown.length}</span> / {total} 个资产{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
{items.length ? (
{shown.length ? (
<div className="asset-grid" id="asset-grid">
{items.map((asset) => {
{shown.map((asset) => {
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
const isVideo = asset.asset_type === "video";
const isAudio = asset.asset_type === "audio";
const previewKind = isVideo ? "video" : isAudio ? "audio" : "image";
const openPreview = cover ? () => setPreview({ src: cover, kind: previewKind, name: asset.name }) : undefined;
const isSelected = selected.has(asset.id);
// 卡片 meta:upload 分类下「上传 · 上传」无信息量,改显资产类型;其余显分类中文
const catLabel = (asset.category === "upload" ? KIND_LABELS[asset.asset_type] : CATEGORY_LABELS[asset.category || ""]) || asset.category;
const srcLabel = SOURCE_LABELS[asset.source || ""] || asset.source;
// 成片卡补导出时间(created_at = 导出入库时刻),格式 2026-06-09 18:21
const exportedAt = asset.category === "final_video" ? (asset.created_at || "").slice(0, 16).replace("T", " ") : "";
const missingTri = personMissingTri(asset);
// 编辑态:整卡点击 = 多选;非编辑态:整卡点击 = 开详情
const onCardClick = editMode ? () => toggleSelect(asset.id) : () => setDetail(asset);
return (
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
<article className={`asset-card ${asset.asset_type}${isSelected ? " selected" : ""}`} key={asset.id} onClick={onCardClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onCardClick(); } }}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
{editMode && onDelete && (
<button className="card-del-btn" type="button" title="删除资产" onClick={(event) => { event.stopPropagation(); setConfirmId(asset.id); }}>
<button className="card-del-btn" type="button" title="删除资产" onClick={(event) => { event.stopPropagation(); setConfirmIds([asset.id]); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
<div className="placeholder asset-thumb" role={openPreview ? "button" : undefined} tabIndex={openPreview ? 0 : undefined} title={openPreview ? (isVideo ? "点击播放" : isAudio ? "点击试听" : "点击放大") : undefined} style={openPreview ? { cursor: isVideo || isAudio ? "pointer" : "zoom-in", position: "relative" } : undefined} onClick={openPreview} onKeyDown={openPreview ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPreview(); } } : undefined}>
<div className="placeholder asset-thumb" style={{ position: "relative" }}>
{missingTri && (
<span className="tri-missing-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明" onClick={(e) => e.stopPropagation()}>
<span className="ico" aria-hidden="true"></span>
<span className="lbl-mono">缺三视图</span>
<span className="tri-missing-pop" role="tooltip">
<span className="pop-h">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
MISSING TRI-VIEW
</span>
<span className="pop-body">手动上传的人物未生成 <b>正 / 侧 / 背</b> 三视图。直接进入图片或视频生成,人脸/服饰一致性可能下降。</span>
<span className="pop-tip">建议:前往 <b>图片生成</b> 先补齐三视图,再发起后续生成。</span>
</span>
</span>
)}
{cover && isVideo && (
<>
<video src={cover} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
@@ -325,18 +694,38 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
<div className="lib-bulk-bar" role="toolbar" aria-label="批量操作">
<span className="ct">已选 <b>{selected.size}</b> 项</span>
<button className="clear-sel" type="button" onClick={() => setSelected(new Set())}>清空</button>
<span className="sep" />
<button className="danger" type="button" disabled={selected.size === 0} onClick={() => selected.size && setConfirmIds(Array.from(selected))}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
删除选中
</button>
<button type="button" onClick={exitEdit}>完成</button>
</div>
<AssetDetailModal
asset={detail}
close={() => setDetail(null)}
onZoom={(src, kind, name) => setPreview({ src, kind, name })}
/>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
<ConfirmModal
open={Boolean(confirmId)}
open={Boolean(confirmIds && confirmIds.length)}
title="删除资产"
detail="确定删除该资产?该操作不可撤销。"
subtitle="// DELETE ASSET"
icon={<Trash2 size={16} />}
detail={`确定删除选中的 ${confirmIds?.length || 0} 个资产?该操作不可撤销。`}
confirmText="删除"
onCancel={() => setConfirmId(null)}
onConfirm={async () => { const id = confirmId; setConfirmId(null); if (id) { await onDelete?.(id); setReloadFlag((f) => f + 1); } }}
onCancel={() => setConfirmIds(null)}
onConfirm={doDelete}
/>
<Drawer title="上传资产" open={drawer} close={() => setDrawer(false)}><form onSubmit={submit}><div className="field"><label className="field-label">文件</label><input className="input file-input" type="file" accept="image/*,video/*,audio/*,.srt,.vtt,.ass" onChange={(event) => setFile(event.target.files?.[0] || null)} /></div><div className="field"><label className="field-label">资产名称</label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="drawer-actions"><button className="btn btn-ghost" type="button" onClick={() => setDrawer(false)}>取消</button><button className="btn btn-primary" type="submit" disabled={!file || uploading}>{uploading ? "上传中…" : "上传资产"}</button></div></form></Drawer>
<UploadModal open={uploadOpen} close={() => setUploadOpen(false)} onSubmit={doUpload} />
</section>
);
}