import { useEffect, useMemo, useRef, useState } from "react"; import type { FormEvent, ReactNode } from "react"; import { createPortal } from "react-dom"; import { Check, ChevronDown, Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Search, Settings2, 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; type AssetBatchResult = { succeededIds: string[]; failedIds: string[]; }; // asset.source / asset.asset_type / asset.category → 中文标签(筛选下拉 + 卡片 meta 用,不给用户看裸枚举) const SOURCE_LABELS: Record = { upload: "上传", ai_generated: "AI 生成", exported: "导出", system: "系统" }; const KIND_LABELS: Record = { image: "图片", video: "视频", audio: "音频", subtitle: "字幕", document: "文档" }; const CATEGORY_LABELS: Record = { 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 = { 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 displayName = asset.display_name?.trim() || asset.name || "视频自由创作"; 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: displayName, product_cover: poster?.preview_url || "", clips: [{ id: asset.id, name: displayName, url: video?.preview_url || "" }] }; } function createdLabel(iso?: string): string { if (!iso) return ""; const date = new Date(iso); if (Number.isNaN(date.getTime())) return ""; const now = new Date(); const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const startThat = new Date(date.getFullYear(), date.getMonth(), date.getDate()); const diffDays = Math.round((startToday.getTime() - startThat.getTime()) / 86400000); if (diffDays === 0) return "今天"; if (diffDays === 1) return "昨天"; return `${String(date.getMonth() + 1).padStart(2, "0")}.${String(date.getDate()).padStart(2, "0")}`; } function videoPackKey(pack: VideoPack): string { return pack.project_id || pack.clips[0]?.id || pack.project_name; } // ───────────────────────────────────────────────────────────────────────── // 资产详情弹窗 · 立绘大图 + 三视图/版本历史(人物)+ 简介/标签/属性 + 媒体画廊(商品) // 用真实 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(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