import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { CSSProperties } from "react"; import { api } from "../api"; import type { Asset, ModelEntity } from "../types"; import { useBodyScrollLock, MediaLightbox } from "./overlays"; // 模特选择器:只读取 Model 顶级实体(含官方跨团队模特),不再混入普通 person 资产。 // 两种用法:browse(浏览 / 添加模特)与 replace(选用一个模特回填当前角色或图片生成)。 const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties); const previewOf = (a: Asset): string => a.files?.find((f) => f.is_primary)?.preview_url || a.files?.[0]?.preview_url || ""; // 模特库实体 → 供现有卡片复用的 Asset 形;id 保持形象资产 id,选中后可直接作为参考图。 const modelToAsset = (m: ModelEntity): Asset => ({ id: m.portrait_asset as string, name: m.name, asset_type: "image", source: m.source === "upload" ? "upload" : "ai_generated", category: "model_portrait", description: "", metadata: { kind: "model", model_entity_id: m.id, is_official: m.is_official }, files: m.portrait ? [{ id: m.portrait_asset as string, object_key: "", bucket: "", content_type: "image/png", size_bytes: 0, preview_url: m.portrait, is_primary: true }] : [], created_at: m.created_at, updated_at: m.updated_at }); export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGenerate, onUpload, onRename, onRefresh }: { open: boolean; mode: "browse" | "replace"; initialStudio?: boolean; // 打开即进「添加模特工作台」 onClose: () => void; onPick?: (assetId: string, assetName?: string) => void | Promise; onGenerate: (prompt: string) => Promise<{ assets: Asset[] } | null>; onUpload: (file: File) => Promise; onRename?: (assetId: string, name: string) => Promise; // 同步底层形象资产名称 onRefresh: () => void; }) { const [studio, setStudio] = useState(Boolean(initialStudio)); // 添加模特工作台 const [studioMode, setStudioMode] = useState<"ai" | "upload">("ai"); const [prompt, setPrompt] = useState(""); const [busy, setBusy] = useState(false); const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); // 添加模特工作台多步态:候选形象图 + 选中图 + 模特名称。 const [candidates, setCandidates] = useState([]); // AI 生成/上传得到的立绘候选 const [picked, setPicked] = useState(null); // 进右侧栏的那张立绘 const [modelName, setModelName] = useState(""); const fileRef = useRef(null); useBodyScrollLock(open); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); // 每次打开按 initialStudio 重置 studio,并清空工作台多步态(避免上次残留) useEffect(() => { if (!open) return; setStudio(Boolean(initialStudio)); setStudioMode("ai"); setCandidates([]); setPicked(null); setModelName(""); }, [open, initialStudio]); // 模特选择器只拉 Model;不再把普通 person 资产并入可选列表。 const [fetched, setFetched] = useState([]); const reload = useCallback(async () => { const models = await api.listModels({ pageSize: 200 }).catch(() => null); const mapped = (models?.results ?? []).filter((m) => m.portrait_asset).map(modelToAsset); setFetched(mapped); }, []); useEffect(() => { if (open) void reload(); }, [open, reload]); const list = useMemo(() => fetched.filter((a) => previewOf(a)), [fetched]); // 模特库分页(客户端):一页 10 个(5 列 × 2 行)。 const PAGE_SIZE = 10; const [page, setPage] = useState(1); const pageCount = Math.max(1, Math.ceil(list.length / PAGE_SIZE)); useEffect(() => { setPage(1); }, [open]); useEffect(() => { setPage((p) => Math.min(p, pageCount)); }, [pageCount]); const pageList = list.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); // 选某张候选形象图 → 进右侧栏命名。 function pickCandidate(a: Asset) { setPicked(a); setModelName(a.name || ""); } // AI 生成立绘候选(不直接关 studio,等用户选一张进下一步) async function genCandidates() { const p = prompt.trim() || "电商真人模特,自然光,干净背景,9:16 竖屏,正面半身"; setBusy(true); try { const res = await onGenerate(p); const got = res?.assets?.filter((a) => previewOf(a)) ?? []; setCandidates(got); if (got.length === 1) pickCandidate(got[0]); // 只有一张就直接进右侧栏 } finally { setBusy(false); } } // 本地上传 → 直接作为选中立绘进右侧栏 async function uploadCandidate(file?: File | null) { if (!file) return; setBusy(true); try { const asset = await onUpload(file); if (asset && previewOf(asset)) { setCandidates([asset]); pickCandidate(asset); } } finally { setBusy(false); } } // 保存模特:同步名称 → 复用当前形象资产创建 Model → 刷新模特库。 async function saveModel() { if (!picked) return; setBusy(true); try { const name = modelName.trim(); if (name && name !== picked.name && onRename) await onRename(picked.id, name); await api.enrollModelFromAsset(picked.id, name || undefined); onRefresh(); await reload(); // 新建模特即时进入唯一的模特列表 setStudio(false); setCandidates([]); setPicked(null); setModelName(""); } finally { setBusy(false); } } if (!open) return null; return createPortal(
{ if (e.target === e.currentTarget) onClose(); }}>

{mode === "replace" ? "模特库 · 选择模特" : "模特库"}

// {mode === "replace" ? "点模特卡即选用应用" : "团队模特 · 官方模板"}
{studio ? (
添加模特
{/* 多步工作台:左 = 生成/上传 + 形象图候选;右 = 模特命名/保存。 */}
{studioMode === "ai" ? (
// 1 描述模特形象(年龄 / 风格 / 妆造 / 背景)→ 生成候选