Files
yingqing/core/frontend/src/routes/library.tsx
T

1044 lines
63 KiB
TypeScript

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, Trash2, Upload, X } from "lucide-react";
import { api } from "../api";
import { SkeletonGrid } from "../components/loading";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import type { Asset, AssetBatch, VideoPack } from "../types";
import { ConfirmModal, MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import { Pager } from "../components/pager";
const LIB_PAGE_SIZE = 10;
// asset.source / asset.asset_type / asset.category → 中文标签(筛选下拉 + 卡片 meta 用,不给用户看裸枚举)
const SOURCE_LABELS: Record<string, string> = { upload: "上传", ai_generated: "AI 生成", exported: "导出", system: "系统" };
const KIND_LABELS: Record<string, string> = { image: "图片", video: "视频", audio: "音频", subtitle: "字幕", document: "文档" };
const CATEGORY_LABELS: Record<string, string> = { person: "角色", scene: "场景", product_image: "商品图", model_tryon: "模特上身图", platform_kit: "平台套图", free_create: "自由创作", video_clip: "视频素材", final_video: "最终成片", upload: "上传", uncategorized: "未分类" };
// 上传文件 → 资产类型:按 MIME 推断,字幕按后缀,兜底文档(后端直接存这个值,不能一律写死 image)
function inferAssetType(file: File): string {
const mime = file.type || "";
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (mime.startsWith("audio/")) return "audio";
const ext = (file.name.split(".").pop() || "").toLowerCase();
if (["srt", "vtt", "ass"].includes(ext)) return "subtitle";
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" }
];
// 资产库 = 成品库(商品/模特/半成品各回各家):
// 图片成品 tryon=模特上身图 / kits=平台套图 / image_creations=图片自由创作;
// video_creations=视频自由创作;视频成品 videopacks=按项目素材包;others=其他兜底
type LibTab = "tryon" | "kits" | "image_creations" | "video_creations" | "videopacks" | "others";
type LibCountKey = LibTab | "creations"; // creations 是旧后端兼容字段,不再作为可见 tab
const LIB_TABS: Array<{ key: LibTab; label: string }> = [
{ key: "tryon", label: "模特上身图" }, { key: "kits", label: "平台套图" }, { key: "image_creations", label: "图片自由创作" },
{ key: "video_creations", label: "视频自由创作" },
{ key: "videopacks", label: "视频成品" }, { key: "others", label: "其他" }
];
// 图片成品三类按「生成批次」成组展示(一次提交的多张图 = 一张批次卡,点开看整批);其余 tab 仍平铺
const BATCH_TABS: LibTab[] = ["tryon", "kits", "image_creations"];
// 对齐 api-bridge:工具栏 chip 按 tab 显隐(视频成品走素材包,不用这些扁平筛选)
const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
{ key: "product", label: "关联商品", tabs: ["tryon", "kits"] },
{ key: "kind", label: "资产类型", tabs: ["others"] },
{ key: "source", label: "来源", tabs: ["tryon", "kits", "image_creations", "video_creations", "others"] }
];
// 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";
}
function freeVideoToPack(asset: Asset): VideoPack {
const files = asset.files || [];
const video = files.find((f) => f.is_primary && (f.content_type || "").startsWith("video/"))
|| files.find((f) => (f.content_type || "").startsWith("video/"));
const poster = files.find((f) => !f.is_primary && (f.content_type || "").startsWith("image/"))
|| files.find((f) => (f.content_type || "").startsWith("image/"));
return {
project_id: `free-video:${asset.id}`,
project_name: asset.name || "视频自由创作",
product_cover: poster?.preview_url || "",
clips: [{ id: asset.id, name: asset.name || "视频", url: video?.preview_url || "" }]
};
}
// ─────────────────────────────────────────────────────────────────────────
// 资产详情弹窗 · 立绘大图 + 三视图/版本历史(人物)+ 简介/标签/属性 + 媒体画廊(商品)
// 用真实 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]);
// 审核盾本地覆盖态:必须在提前 return 之前声明,否则 hook 数量随 asset 有无变化 → React 崩溃白屏
const [reviewOverride, setReviewOverride] = useState<string | null>(null);
const [submittingReview, setSubmittingReview] = useState(false);
useEffect(() => { setReviewOverride(null); }, [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 }
];
// 审核盾:仅送审类(角色/三视图/分镜)显示;灰盾可点提交。本地覆盖提交后即时反映,切资产清空
// (状态 hook 已上移到提前 return 之前)
const REVIEW_CATS = ["person", "tri_view", "storyboard"];
const review = (reviewOverride ?? asset.review_status ?? "") as ReviewStatus;
async function submitReview() {
if (!asset) return;
setSubmittingReview(true);
try {
const r = await api.submitAssetReview(asset.id);
setReviewOverride(r.review_status || "processing");
} catch { /* 提交失败保持灰盾,用户可再点 */ } finally {
setSubmittingReview(false);
}
}
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_CATS.includes(asset.category) && (
<ReviewBadge status={review} error={asset.review_error} busy={submittingReview} onSubmit={submitReview} />
)}
<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>("tryon");
const [query, setQuery] = useState("");
const [uploadOpen, setUploadOpen] = useState(false);
const [openChip, setOpenChip] = useState("");
const [srcFilter, setSrcFilter] = useState("");
const [kindFilter, setKindFilter] = useState("");
// 关联商品筛选(仅图片成品 tab):存商品 id,选中后把 product=<id> 传进列表/批次请求;选项走 facets.products
const [productFilter, setProductFilter] = useState("");
const [sortDesc, setSortDesc] = useState(true);
// 编辑模式 + 多选 + 元数据筛选(性别/年龄/角色/场景类型/关联/时长 走 asset.metadata,真实存在才有可选项)
const [editMode, setEditMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
// 批次多选(图片成品 tab):按 batch_id 存;删除时展开成整批 asset id(见 bulkDeleteIds)
const [selectedBatches, setSelectedBatches] = useState<Set<string>>(new Set());
const [metaFilter, setMetaFilter] = useState<Record<string, string>>({});
// 删除确认:单删传 [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 toggleBatch = (batchId: string) => setSelectedBatches((prev) => {
const next = new Set(prev);
if (next.has(batchId)) next.delete(batchId); else next.add(batchId);
return next;
});
const clearSelection = () => { setSelected(new Set()); setSelectedBatches(new Set()); };
const exitEdit = () => { setEditMode(false); clearSelection(); };
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
const [items, setItems] = useState<Asset[]>([]);
const [total, setTotal] = useState(0);
const [counts, setCounts] = useState<Record<LibCountKey, number>>({ tryon: 0, kits: 0, image_creations: 0, video_creations: 0, videopacks: 0, others: 0, creations: 0 });
// 图片成品:按生成批次成组(点批次卡 → 弹窗看整批图片)
const [batches, setBatches] = useState<AssetBatch[]>([]);
const [openBatch, setOpenBatch] = useState<AssetBatch | null>(null);
// 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段)
const [packs, setPacks] = useState<VideoPack[]>([]);
const [openPack, setOpenPack] = useState<VideoPack | null>(null);
const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]>; products: { id: string; title: string }[] }>({ sources: [], kinds: [], metadata: {}, products: [] });
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
// 搜索去抖:输入停 400ms 才打接口,避免逐字符请求
const [debouncedQuery, setDebouncedQuery] = useState("");
useEffect(() => { const id = setTimeout(() => setDebouncedQuery(query), 400); return () => clearTimeout(id); }, [query]);
// 切 tab / 改筛选 / 改排序回到第 1 页
useEffect(() => { setPage(1); }, [tab, debouncedQuery, srcFilter, kindFilter, productFilter, metaFilter, sortDesc]);
// 切 tab 时清空与该 tab 无关的筛选 + 退出多选(避免跨 tab 误删/误筛)
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setProductFilter(""); setMetaFilter({}); setSelected(new Set()); setSelectedBatches(new Set()); }, [tab]);
// 当前 tab 的 metadata 筛选键(来源/类型/关联商品走独立字段与 facets,不算 metadata)
const metaKeys = LIB_CHIPS.filter((c) => c.tabs.includes(tab) && c.key !== "source" && c.key !== "kind" && c.key !== "product").map((c) => c.key);
// 图片成品三类按批次成组展示;其余 tab 平铺
const isBatchTab = BATCH_TABS.includes(tab);
const isVideoCreationTab = tab === "video_creations";
const imageBatchCount = counts.tryon + counts.kits + counts.image_creations;
const resultUnit = isBatchTab ? "个批次" : (tab === "video_creations" ? "条视频" : "个资产");
// 列表数据(分页 + 过滤)
const [reloadFlag, setReloadFlag] = useState(0);
useEffect(() => {
if (tab === "videopacks") { setLoading(false); return; } // 视频成品走素材包,不拉扁平资产
let alive = true;
setLoading(true);
const common = {
tab,
q: debouncedQuery || undefined,
source: srcFilter || undefined,
product: productFilter || undefined, // 后端 assetsPage/batches 两条通道都按 ?product= 三路过滤
meta: metaFilter,
ordering: sortDesc ? "-created_at" : "created_at",
page,
pageSize: LIB_PAGE_SIZE
};
if (isBatchTab) {
// 图片成品:按生成批次成组(每批一张卡)
api.assetBatches(common)
.then((res) => { if (alive) { setBatches(res.results); setTotal(res.count); } })
.catch(() => { if (alive) { setBatches([]); setTotal(0); } })
.finally(() => { if (alive) setLoading(false); });
} else {
api.assetsPage({ ...common, asset_type: kindFilter || undefined })
.then((res) => { if (alive) { setItems(res.results); setTotal(res.count); } })
.catch(() => { if (alive) { setItems([]); setTotal(0); } })
.finally(() => { if (alive) setLoading(false); });
}
return () => { alive = false; };
}, [tab, isBatchTab, debouncedQuery, srcFilter, kindFilter, productFilter, metaFilter, sortDesc, page, reloadFlag]);
// tab 计数(徽标)+ 当前 tab 的筛选项(下拉「只列真有的」):切 tab / 上传 / 删除后刷新
useEffect(() => { api.assetSummary().then((c) => setCounts((prev) => ({ ...prev, ...c }))).catch(() => {}); }, [reloadFlag]);
// 视频成品素材包:进页/刷新就拉一次,顺带把项目包数喂给 tab 徽标
useEffect(() => {
let alive = true;
api.videoPacks().then((ps) => { if (alive) { setPacks(ps); setCounts((prev) => ({ ...prev, videopacks: ps.length })); } }).catch(() => { if (alive) setPacks([]); });
return () => { alive = false; };
}, [reloadFlag]);
useEffect(() => {
let alive = true;
api.assetFacets(tab, metaKeys).then((f) => { if (alive) setFacets(f); }).catch(() => {});
return () => { alive = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, reloadFlag]);
const srcOptions = facets.sources;
const kindOptions = facets.kinds;
const metaOptions = (key: string) => facets.metadata[key] || [];
const hasFilter = Boolean(debouncedQuery || srcFilter || kindFilter || productFilter || 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));
// 视频成品:前端全量数组按搜索(项目名)过滤 + 排序(按片段数)+ 分页切片(10/页,共享 Pager)
const filteredPacks = useMemo(() => {
const q = debouncedQuery.trim().toLowerCase();
const arr = q ? packs.filter((p) => (p.project_name || "").toLowerCase().includes(q)) : packs.slice();
return arr.sort((a, b) => (sortDesc ? b.clips.length - a.clips.length : a.clips.length - b.clips.length));
}, [packs, debouncedQuery, sortDesc]);
const packTotalPages = Math.max(1, Math.ceil(filteredPacks.length / LIB_PAGE_SIZE));
const packCurPage = Math.min(page, packTotalPages);
const shownPacks = filteredPacks.slice((packCurPage - 1) * LIB_PAGE_SIZE, packCurPage * LIB_PAGE_SIZE);
const openPackIsFreeVideo = Boolean(openPack?.project_id?.startsWith("free-video:"));
// 批量删除:批次 tab 走 selectedBatches(展开成整批 asset id),其余 tab 走 selected(asset id)
const selCount = isBatchTab ? selectedBatches.size : selected.size;
const bulkDeleteIds = () => (isBatchTab
? batches.filter((b) => selectedBatches.has(b.batch_id)).flatMap((b) => b.items.map((a) => a.id))
: Array.from(selected));
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openChip]);
// 上传:拼 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", assetType);
formData.append("category", "upload");
await onUpload(formData);
setReloadFlag((f) => f + 1);
}
// 删除(单 / 批):乐观隐藏 → 并发删 → 刷新计数/列表。后端为回收站软删(R108),可恢复。
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());
setSelectedBatches(new Set());
// R108:批次弹窗内单张删除 —— 同步从打开中的批次里摘掉;删空则关掉弹窗
setOpenBatch((prev) => {
if (!prev) return prev;
const items = prev.items.filter((a) => !ids.includes(a.id));
if (!items.length) return null;
const cover = items[0].files?.find((f) => f.is_primary)?.preview_url || items[0].files?.[0]?.preview_url || prev.cover;
return { ...prev, items, count: items.length, cover };
});
try {
await Promise.all(ids.map((id) => onDelete(id)));
setReloadFlag((f) => f + 1);
} finally {
setDeletingIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.delete(id)); return next; });
}
}
return (
<section className="library-page">
<div className="page-head">
<div>
<h1>资产库</h1>
<div className="sub"><span className="mono">// 你的成品 · 图片 {imageBatchCount} 批 · 视频 {counts.video_creations} 条 · 视频成品 {counts.videopacks} 包</span></div>
</div>
<div className="actions">
<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>
{/* 上传资产入口已隐藏:资产由 AI 生成流水线自动入库,不开放手动上传 */}
</div>
</div>
<div className="tabs" id="asset-tabs">
{LIB_TABS.map((t) => (
<div className={`tab${tab === t.key ? " active" : ""}`} key={t.key} data-tab={t.key} onClick={() => setTab(t.key)}>{t.label} <span className="count">{counts[t.key]}</span></div>
))}
</div>
{tab === "videopacks" ? (
<>
<div className="toolbar">
<div className="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" placeholder="搜索项目名" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<span className="spacer"></span>
<div className={`chip-wrap${openChip === "sort" ? " open" : ""}`} data-key="sort">
<button className="chip" type="button" onClick={() => setOpenChip((c) => (c === "sort" ? "" : "sort"))}>
<span className="chip-label">{sortDesc ? "片段最多" : "片段最少"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu align-right">
<div className={`mi${sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(true); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>片段最多
</div>
<div className={`mi${!sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(false); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>片段最少
</div>
</div>
</div>
</div>
<div className="result-meta">// 显示 <span className="count">{shownPacks.length}</span> / {filteredPacks.length} 个素材包{debouncedQuery ? "(已筛选)" : ""}</div>
<div className="packs-grid">
{filteredPacks.length === 0 ? (
<div className="empty-filter">// {debouncedQuery ? "没有匹配的视频成品" : "还没有视频成品 · 去视频项目生成片段"}</div>
) : (
shownPacks.map((pack) => {
const first = pack.clips[0];
// 视频成品的所有片段 id:批量删除整个素材包
const packClipIds = pack.clips.map((c) => c.id).filter(Boolean);
return (
<article className="pack-card" key={pack.project_id || pack.project_name} onClick={() => !editMode && setOpenPack(pack)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (!editMode) setOpenPack(pack); } }}>
{editMode && onDelete && packClipIds.length > 0 && (
<button className="card-del-btn" type="button" title="删除素材包" onClick={(event) => { event.stopPropagation(); setConfirmIds(packClipIds); }}>
<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 pack-thumb">
{first?.url ? (
<video src={first.url} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
) : pack.product_cover ? (
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
) : (
<span className="ph-frame">无片段</span>
)}
<span className="pack-count mono">{pack.clips.length} </span>
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</div>
<div className="asset-body"><div className="asset-name">{pack.project_name}</div><div className="asset-meta mono">视频素材包</div></div>
</article>
);
})
)}
</div>
<Pager page={packCurPage} total={filteredPacks.length} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
</>
) : (
<>
<div className="toolbar">
<div className="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" id="search-input" placeholder="搜索资产名称、标签" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
{LIB_CHIPS.filter((chip) => chip.tabs.includes(tab)).map((chip) => {
// 仅「来源 / 资产类型」有真实字段可筛;其余(性别/年龄/角色/场景类型/关联商品/关联项目/时长)Asset 无对应字段,保持静态
if (chip.key === "source") {
return (
<div className={`chip-wrap${openChip === "source" ? " open" : ""}`} data-key="source" key="source">
<button className={`chip${srcFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "source" ? "" : "source"))}>
<span className="chip-label">{srcFilter ? SOURCE_LABELS[srcFilter] || srcFilter : "来源"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!srcFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSrcFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部来源
</div>
{srcOptions.length > 0 && <div className="mi-sep" />}
{srcOptions.map((src) => (
<div className={`mi${srcFilter === src ? " selected" : ""}`} key={src} role="button" tabIndex={0} onClick={() => { setSrcFilter(src); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{SOURCE_LABELS[src] || src}
</div>
))}
</div>
</div>
);
}
if (chip.key === "kind") {
return (
<div className={`chip-wrap${openChip === "kind" ? " open" : ""}`} data-key="kind" key="kind">
<button className={`chip${kindFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "kind" ? "" : "kind"))}>
<span className="chip-label">{kindFilter ? KIND_LABELS[kindFilter] || kindFilter : "资产类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!kindFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setKindFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部类型
</div>
{kindOptions.length > 0 && <div className="mi-sep" />}
{kindOptions.map((k) => (
<div className={`mi${kindFilter === k ? " selected" : ""}`} key={k} role="button" tabIndex={0} onClick={() => { setKindFilter(k); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{KIND_LABELS[k] || k}
</div>
))}
</div>
</div>
);
}
if (chip.key === "product") {
// 关联商品:选项走 facets.products(本 tab 下真实关联到的商品),选中后 product=<id> 进列表/批次请求
const curTitle = facets.products.find((p) => p.id === productFilter)?.title || "";
return (
<div className={`chip-wrap${openChip === "product" ? " open" : ""}`} data-key="product" key="product">
<button className={`chip${productFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
<span className="chip-label">{productFilter ? (curTitle || "关联商品") : "关联商品"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!productFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setProductFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部商品
</div>
{facets.products.length > 0 && <div className="mi-sep" />}
{facets.products.map((p) => (
<div className={`mi${productFilter === p.id ? " selected" : ""}`} key={p.id} role="button" tabIndex={0} onClick={() => { setProductFilter(p.id); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{p.title}
</div>
))}
</div>
</div>
);
}
// 其余维度走 asset.metadata 真实派生:有标记数据才出可选项,否则只「全部」
const opts = metaOptions(chip.key);
const cur = metaFilter[chip.key] || "";
return (
<div className={`chip-wrap${openChip === chip.key ? " open" : ""}`} data-key={chip.key} key={chip.key}>
<button className={`chip${cur ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === chip.key ? "" : chip.key))}>
<span className="chip-label">{cur || chip.label}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!cur ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: "" })); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部{chip.label}
</div>
{opts.length > 0 && <div className="mi-sep" />}
{opts.map((v) => (
<div className={`mi${cur === v ? " selected" : ""}`} key={v} role="button" tabIndex={0} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: v })); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{v}
</div>
))}
</div>
</div>
);
})}
<span className="spacer"></span>
<div className={`chip-wrap${openChip === "sort" ? " open" : ""}`} data-key="sort">
<button className="chip" type="button" onClick={() => setOpenChip((c) => (c === "sort" ? "" : "sort"))}>
<span className="chip-label">{sortDesc ? "最近添加" : "最早添加"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu align-right">
<div className={`mi${sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(true); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>最近添加
</div>
<div className={`mi${!sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(false); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>最早添加
</div>
</div>
</div>
</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{isBatchTab ? batches.length : shown.length}</span> / {total} {resultUnit}{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
{isBatchTab ? (
batches.length ? (
<div className="packs-grid" id="batch-grid">
{batches.map((batch) => {
const isSel = selectedBatches.has(batch.batch_id);
// 编辑态:整卡点击 = 选中/取消该批次(走 lib-bulk-bar 批量删);非编辑态:点开批次弹窗
const onBatchClick = editMode ? () => toggleBatch(batch.batch_id) : () => setOpenBatch(batch);
return (
<article className={`pack-card batch-card${editMode && isSel ? " selected" : ""}`} key={batch.batch_id} onClick={onBatchClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onBatchClick(); } }}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
<div className="placeholder asset-thumb pack-thumb">
{batch.cover ? <img src={batch.cover} alt={batch.name} loading="lazy" /> : <span className="ph-frame">无图</span>}
<span className="pack-count mono">{batch.count} </span>
</div>
<div className="asset-body"><div className="asset-name">{batch.name}</div><div className="asset-meta mono">生成批次</div></div>
</article>
);
})}
</div>
) : loading ? (
<SkeletonGrid count={8} />
) : (
<div className="empty-filter">// 当前分类暂无真实资产</div>
)
) : isVideoCreationTab ? (
shown.length ? (
<div className="packs-grid" id="free-video-grid">
{shown.map((asset) => {
const pack = freeVideoToPack(asset);
const first = pack.clips[0];
const isSelected = selected.has(asset.id);
const onVideoClick = editMode ? () => toggleSelect(asset.id) : () => setOpenPack(pack);
return (
<article className={`pack-card batch-card${isSelected ? " selected" : ""}`} key={asset.id} onClick={onVideoClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onVideoClick(); } }}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
{editMode && onDelete && (
<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 pack-thumb">
{pack.product_cover ? (
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
) : first?.url ? (
<video src={first.url} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
) : (
<span className="ph-frame">无视频</span>
)}
<span className="pack-count mono">1 </span>
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</div>
<div className="asset-body"><div className="asset-name">{pack.project_name}</div><div className="asset-meta mono">视频自由创作</div></div>
</article>
);
})}
</div>
) : loading ? (
<SkeletonGrid count={8} />
) : (
<div className="empty-filter">// 当前分类暂无真实资产</div>
)
) : shown.length ? (
<div className="asset-grid" id="asset-grid">
{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 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}${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(); 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" 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" }} />
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</>
)}
{isAudio && (
// 音频没有可视封面,preview_url 是音频文件本身,塞 <img> 必裂图 → 用默认音符占位
<span className="lib-audio-ph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg>
<span className="mono">AUDIO</span>
</span>
)}
{cover && !isVideo && !isAudio && <img src={cover} alt={asset.name} loading="lazy" />}
{!cover && !isAudio && <span className="ph-frame">{KIND_LABELS[asset.asset_type] || asset.asset_type}</span>}
</div>
<div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{catLabel} · {srcLabel}{exportedAt && <span className="asset-meta-time"> · {exportedAt}</span>}</div></div>
</article>
);
})}
</div>
) : loading ? (
<SkeletonGrid count={8} />
) : (
<div className="empty-filter">// 当前分类暂无真实资产</div>
)}
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
</>
)}
{/* 视频素材包弹窗:看该项目所有视频片段(可播) */}
{openPack && createPortal(
<div className="pack-modal-bg" role="dialog" aria-modal="true" aria-label={openPackIsFreeVideo ? "视频自由创作" : "视频素材包"} onClick={(e) => { if (e.target === e.currentTarget) setOpenPack(null); }}>
<div className="pack-modal">
<div className="pack-modal-h">
<div>
<h2>{openPack.project_name}</h2>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {openPackIsFreeVideo ? "视频自由创作" : "视频素材包"} · {openPack.clips.length} {openPackIsFreeVideo ? "条" : "段"}</span>
</div>
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenPack(null)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
</div>
<div className="pack-clip-grid">
{openPack.clips.map((c, i) => (
<div className="pack-clip" key={c.id}>
{c.url ? <video src={c.url} controls muted playsInline preload="metadata" /> : <span className="pack-clip-ph ph-frame">无视频</span>}
<div className="pack-clip-name mono">{openPackIsFreeVideo ? (c.name || "视频") : `镜 ${i + 1}`}</div>
</div>
))}
</div>
</div>
</div>,
document.body
)}
{/* 生成批次弹窗:看该批次所有图片(点单图开详情/大图)。
under-confirm:批次弹窗内点单图删除会弹 ConfirmModal(.modal-bg),故批次层必须降到 .modal-bg 之下,否则二次确认被压住看不见(R109 修复) */}
{openBatch && createPortal(
<div className="pack-modal-bg under-confirm" role="dialog" aria-modal="true" aria-label="生成批次" onClick={(e) => { if (e.target === e.currentTarget) setOpenBatch(null); }}>
<div className="pack-modal">
<div className="pack-modal-h">
<div>
<h2>{openBatch.name}</h2>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// 生成批次 · {openBatch.count} 张</span>
</div>
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenBatch(null)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
</div>
<div className="pack-clip-grid">
{openBatch.items.map((a, i) => {
const cover = a.files?.find((f) => f.is_primary)?.preview_url || a.files?.[0]?.preview_url || "";
return (
// R108:图片集内单张悬浮删除 icon(嵌套按钮非法 → 外层改 div[role=button])
<div
className="pack-clip as-btn"
role="button"
tabIndex={0}
key={a.id}
onClick={() => { setOpenBatch(null); setDetail(a); }}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(null); setDetail(a); } }}
title="查看详情"
>
{onDelete && (
<button className="card-del-btn" type="button" title="删除这张图片" onClick={(event) => { event.stopPropagation(); setConfirmIds([a.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>
)}
{cover ? <img src={cover} alt={a.name} loading="lazy" /> : <span className="pack-clip-ph ph-frame">无图</span>}
<div className="pack-clip-name mono"> {i + 1} </div>
</div>
);
})}
</div>
</div>
</div>,
document.body
)}
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
<div className={`lib-bulk-bar${selCount > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
<span className="ct">已选 <b>{selCount}</b> {isBatchTab ? "批" : "项"}</span>
<button className="clear-sel" type="button" onClick={clearSelection}>清空</button>
<span className="sep" />
<button className="danger" type="button" disabled={selCount === 0} onClick={() => { const ids = bulkDeleteIds(); if (ids.length) setConfirmIds(ids); }}>
<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(confirmIds && confirmIds.length)}
title="删除资产"
subtitle="// DELETE ASSET"
icon={<Trash2 size={16} />}
detail={`已选中 ${confirmIds?.length || 0} 个资产。删除的资产将回收到垃圾桶,是否需要删除?`}
confirmText="删除"
onCancel={() => setConfirmIds(null)}
onConfirm={doDelete}
/>
<UploadModal open={uploadOpen} close={() => setUploadOpen(false)} onSubmit={doUpload} />
</section>
);
}