后端: - 独立生图三类归类:model+product→model_tryon(模特上身图,引用模特库,不送审) / cover→platform_kit(平台套图) / image→free_create(自由创作);生成演员(model 无 product)仍→person(视频角色,留期3) - 成组:每次提交一个 batch_id 串起整批,落 asset.metadata;另记 mode + model_entity_id(上身图溯源模特库) - generate-image 端点透传 model_entity_id;资产库 _tab_q+summary 加 tryon/kits/creations 三类桶 - 单测 StandaloneCategoryTests 直跑 worker 验四态归类全绿(绕开异步 .delay) 前端: - 资产库 library.tsx 加三类 tab(模特上身图/平台套图/自由创作)+ 计数 + 筛选维度 - ai-tools 模特选择器数据源 person→模特库(listModels 映射,选中传形象图当参考 = 引用模特库),ActorLibrary 同源 - api.ts submitGenerateImage 加 model_entity_id 验收:tsc+build 全绿;无头 0 console error(资产库三类 tab 各归类正确、模特选择器 5 张取自模特库) 基线既有 3 失败(StandaloneImageReferenceTests 异步漂移)零新增 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
734 lines
43 KiB
TypeScript
734 lines
43 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, Shield, Trash2, Upload, X } from "lucide-react";
|
|
import { api } from "../api";
|
|
import { SkeletonGrid } from "../components/loading";
|
|
import type { Asset } 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: "商品图", 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" }
|
|
];
|
|
|
|
// 图片趴三类(期2):tryon=模特上身图 / kits=平台套图 / creations=自由创作
|
|
type LibTab = "people" | "scenes" | "products" | "tryon" | "kits" | "creations" | "finals" | "uploads" | "unclassified";
|
|
|
|
const LIB_TABS: Array<{ key: LibTab; label: string }> = [
|
|
{ key: "people", label: "人物" }, { key: "scenes", label: "场景" }, { key: "products", label: "商品图" },
|
|
{ key: "tryon", label: "模特上身图" }, { key: "kits", label: "平台套图" }, { key: "creations", label: "自由创作" },
|
|
{ key: "finals", label: "成片" }, { key: "uploads", label: "我的上传" }, { key: "unclassified", label: "未分类" }
|
|
];
|
|
|
|
// 对齐 api-bridge:工具栏 chip 按 tab 显隐
|
|
const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
|
|
{ key: "gender", label: "性别", tabs: ["people"] },
|
|
{ key: "age", label: "年龄段", tabs: ["people"] },
|
|
{ key: "role", label: "角色标签", tabs: ["people"] },
|
|
{ key: "sceneType", label: "场景类型", tabs: ["scenes"] },
|
|
{ key: "product", label: "关联商品", tabs: ["products", "tryon", "kits"] },
|
|
{ key: "project", label: "关联项目", tabs: ["finals"] },
|
|
{ key: "duration", label: "时长", tabs: ["finals"] },
|
|
{ key: "kind", label: "资产类型", tabs: ["uploads"] },
|
|
{ key: "source", label: "来源", tabs: ["people", "scenes", "products", "tryon", "kits", "creations", "uploads"] }
|
|
];
|
|
|
|
// 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 [uploadOpen, setUploadOpen] = useState(false);
|
|
const [openChip, setOpenChip] = useState("");
|
|
const [srcFilter, setSrcFilter] = useState("");
|
|
const [kindFilter, setKindFilter] = useState("");
|
|
const [sortDesc, setSortDesc] = useState(true);
|
|
// 编辑模式 + 多选 + 元数据筛选(性别/年龄/角色/场景类型/关联/时长 走 asset.metadata,真实存在才有可选项)
|
|
const [editMode, setEditMode] = useState(false);
|
|
const [selected, setSelected] = 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 exitEdit = () => { setEditMode(false); setSelected(new Set()); };
|
|
|
|
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
|
|
const [items, setItems] = useState<Asset[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [counts, setCounts] = useState<Record<LibTab, number>>({ people: 0, scenes: 0, products: 0, tryon: 0, kits: 0, creations: 0, finals: 0, uploads: 0, unclassified: 0 });
|
|
const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]> }>({ sources: [], kinds: [], metadata: {} });
|
|
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, metaFilter, sortDesc]);
|
|
// 切 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);
|
|
|
|
// 列表数据(分页 + 过滤)
|
|
const [reloadFlag, setReloadFlag] = useState(0);
|
|
useEffect(() => {
|
|
let alive = true;
|
|
setLoading(true);
|
|
api.assetsPage({
|
|
tab,
|
|
q: debouncedQuery || undefined,
|
|
source: srcFilter || undefined,
|
|
asset_type: kindFilter || undefined,
|
|
meta: metaFilter,
|
|
ordering: sortDesc ? "-created_at" : "created_at",
|
|
page,
|
|
pageSize: LIB_PAGE_SIZE
|
|
}).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, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc, page, reloadFlag]);
|
|
|
|
// tab 计数(徽标)+ 当前 tab 的筛选项(下拉「只列真有的」):切 tab / 上传 / 删除后刷新
|
|
useEffect(() => { api.assetSummary().then((c) => setCounts((prev) => ({ ...prev, ...c }))).catch(() => {}); }, [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 || 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;
|
|
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);
|
|
}
|
|
|
|
// 删除(单 / 批):乐观隐藏 → 并发删 → 刷新计数/列表
|
|
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 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">// 跨项目复用 · <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={() => (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={() => 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>
|
|
</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>
|
|
|
|
<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>
|
|
);
|
|
}
|
|
// 其余维度走 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">{shown.length}</span> / {total} 个资产{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</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} />
|
|
|
|
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
|
|
<div className={`lib-bulk-bar${selected.size > 0 ? " show" : ""}`} 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(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>
|
|
);
|
|
}
|