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 } from "../types"; import { useBodyScrollLock, MediaLightbox } from "./overlays"; // 流程步骤4 · 演员库:平台预设演员 + 我的演员(本地上传/AI 生成)。 // 两种用法: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 || ""; // 平台预设 = 模特库生成的(metadata.kind==="model")或系统来源;其余 person 资产归「我的演员」 const isPreset = (a: Asset): boolean => (a.metadata?.kind as string) === "model" || a.source === "system" || a.source === "ai_generated"; export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPick, onGenerate, onUpload, onGenerateTriview, onRename, onRefresh }: { open: boolean; mode: "browse" | "replace"; initialStudio?: boolean; // 打开即进「添加人物工作台」 assets: Asset[]; onClose: () => void; onPick?: (assetId: string, assetName?: string) => void | Promise; onGenerate: (prompt: string) => Promise<{ assets: Asset[] } | null>; onUpload: (file: File) => Promise; // 据选中立绘生成配套三视图(后端吃任意 person 资产 id,不依赖该资产已在某 base group) onGenerateTriview?: (portraitAssetId: string) => Promise<{ id: string } | null>; onRename?: (assetId: string, name: string) => Promise; // 给人物命名(写回资产 name) onRefresh: () => void; }) { const [tab, setTab] = useState<"preset" | "mine">("preset"); 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 [actorName, setActorName] = useState(""); const [triReady, setTriReady] = useState(false); // 已据选中立绘生成过三视图 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); setActorName(""); setTriReady(false); }, [open, initialStudio]); // 演员库自取 person 资产:全局 assets 已不再全量预载(按需懒加载),不能再依赖传入的 assets prop, // 否则平台预设/我的演员全空。打开时拉一页(团队 person 资产),保存后 reload 让新人物即时入列。 const [fetched, setFetched] = useState([]); const reload = useCallback(async () => { const res = await api.assetsPage({ category: "person", pageSize: 200, ordering: "-created_at" }).catch(() => null); setFetched(res?.results ?? []); }, []); useEffect(() => { if (open) void reload(); }, [open, reload]); // person 类资产 → 演员;按 preset / mine 分两 tab。合并 prop(兼容旧调用)+ 自取,按 id 去重。 const source = useMemo(() => { const map = new Map(); for (const a of [...assets, ...fetched]) map.set(a.id, a); return [...map.values()]; }, [assets, fetched]); const people = useMemo(() => source.filter((a) => a.category === "person" && previewOf(a)), [source]); const list = people.filter((a) => (tab === "preset" ? isPreset(a) : !isPreset(a))); // 演员库分页(客户端):一页 10 个(5 列 × 2 行)。切 tab / 列表变化时回第 1 页,避免停在越界页。 const PAGE_SIZE = 10; const [page, setPage] = useState(1); const pageCount = Math.max(1, Math.ceil(list.length / PAGE_SIZE)); useEffect(() => { setPage(1); }, [tab, 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); setActorName(a.name || ""); setTriReady(false); } // 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); } } // 据选中立绘生成三视图(后端异步出图,完成后刷新即见;本地无 worker 时点了不报错) async function genTriview() { if (!picked || !onGenerateTriview) return; setBusy(true); try { const r = await onGenerateTriview(picked.id); if (r) setTriReady(true); } finally { setBusy(false); } } // 保存人物:写名字(若有改名能力)→ 刷新 → 关工作台 → 切「我的演员」tab async function saveActor() { if (!picked) return; setBusy(true); try { const name = actorName.trim(); if (name && name !== picked.name && onRename) await onRename(picked.id, name); onRefresh(); void reload(); // 新保存的人物即时进「我的演员」列表(自取,不依赖父级全局 assets) setStudio(false); setCandidates([]); setPicked(null); setActorName(""); setTriReady(false); setTab("mine"); } finally { setBusy(false); } } if (!open) return null; return createPortal(
{ if (e.target === e.currentTarget) onClose(); }}>

{mode === "replace" ? "演员库 · 选择演员" : "演员库"}

// {mode === "replace" ? "点演员卡即选用应用" : "平台预设演员 / 我的演员"}
{studio ? (
添加人物工作台
{/* 多步工作台:左 = 生成/上传 + 立绘候选;右 = 选中立绘的命名/三视图/保存 */}
{studioMode === "ai" ? (
// 1 描述演员形象(年龄 / 风格 / 妆造 / 背景)→ 生成立绘候选