1044 lines
63 KiB
TypeScript
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>
|
|
);
|
|
}
|