Files
yingqing/core/frontend/src/components/model-library.tsx
T

259 lines
16 KiB
TypeScript

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<unknown>;
onGenerate: (prompt: string) => Promise<{ assets: Asset[] } | null>;
onUpload: (file: File) => Promise<Asset | null>;
onRename?: (assetId: string, name: string) => Promise<unknown>; // 同步底层形象资产名称
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<Asset[]>([]); // AI 生成/上传得到的立绘候选
const [picked, setPicked] = useState<Asset | null>(null); // 进右侧栏的那张立绘
const [modelName, setModelName] = useState("");
const fileRef = useRef<HTMLInputElement | null>(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<Asset[]>([]);
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(
<div className="actorlib-bg" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="actorlib" role="dialog" aria-modal="true" aria-label="模特库">
<div className="actorlib-h">
<h2>{mode === "replace" ? "模特库 · 选择模特" : "模特库"}</h2>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {mode === "replace" ? "点模特卡即选用应用" : "团队模特 · 官方模板"}</span>
<button className="x" type="button" aria-label="关闭" onClick={onClose}>
<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>
{studio ? (
<div className="actorlib-body">
<div className="actorlib-studio-h">
<button className="btn btn-ghost btn-sm" type="button" onClick={() => setStudio(false)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M19 12H5M12 19l-7-7 7-7" /></svg>
返回模特库
</button>
<strong style={{ fontSize: 14 }}>添加模特</strong>
</div>
<div className="actorlib-tabs" style={{ marginBottom: 14 }}>
<button className={`al-tab${studioMode === "ai" ? " active" : ""}`} type="button" onClick={() => { setStudioMode("ai"); setCandidates([]); setPicked(null); }}>AI 生成</button>
<button className={`al-tab${studioMode === "upload" ? " active" : ""}`} type="button" onClick={() => { setStudioMode("upload"); setCandidates([]); setPicked(null); }}>本地上传</button>
</div>
{/* 多步工作台:左 = 生成/上传 + 形象图候选;右 = 模特命名/保存。 */}
<div className="actorlib-studio-grid">
<div className="actorlib-studio-left">
{studioMode === "ai" ? (
<div className="actorlib-studio-ai">
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 1 描述模特形象(年龄 / 风格 / 妆造 / 背景)→ 生成候选</div>
<textarea className="asset-prompt-edit" rows={3} placeholder="如:25 岁都市白领女性,通勤淡妆,简约棚拍背景,9:16 竖屏正面半身" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<button className="btn btn-primary" type="button" disabled={busy} style={{ marginTop: 12 }} onClick={() => void genCandidates()}>
{busy ? "生成中…" : "生成立绘"}
</button>
</div>
) : (
<div className="actorlib-studio-upload">
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 1 上传本地模特形象图 → 右侧命名并保存</div>
<div className="actorlib-drop" role="button" tabIndex={0} onClick={() => fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
<div style={{ marginTop: 10, fontSize: 13 }}>{busy ? "上传中…" : "点击选择本地模特图片上传"}</div>
<div className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)", marginTop: 4 }}>// JPG / PNG / WEBP</div>
</div>
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { void uploadCandidate(e.target.files?.[0]); e.currentTarget.value = ""; }} />
</div>
)}
{candidates.length > 0 && (
<>
<div className="muted mono" style={{ fontSize: 12, margin: "14px 0 6px", letterSpacing: ".04em" }}>// 2 选一张形象图 → 右侧命名并保存模特</div>
<div className="actorlib-grid">
{candidates.map((a) => {
const url = previewOf(a);
return (
<div className="actor-card" key={a.id}>
<div className={`placeholder actor-thumb${url ? " has-mock-media" : ""}${picked?.id === a.id ? " active" : ""}`} style={url ? mediaStyle(url) : undefined}
role="button" tabIndex={0} title="选用这张立绘"
onClick={() => pickCandidate(a)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pickCandidate(a); } }}>
{!url && <span className="ph-frame">{a.name}</span>}
<span className="actor-pick">{picked?.id === a.id ? "已选" : "选用"}</span>
</div>
</div>
);
})}
</div>
</>
)}
</div>
<div className="actorlib-studio-right">
{picked ? (
<div className="actorlib-studio-detail">
<div className={`placeholder actor-thumb${previewOf(picked) ? " has-mock-media" : ""}`} style={previewOf(picked) ? mediaStyle(previewOf(picked)) : undefined}>
{!previewOf(picked) && <span className="ph-frame">{picked.name}</span>}
</div>
<label className="muted mono" style={{ fontSize: 12, margin: "12px 0 4px", display: "block", letterSpacing: ".04em" }}>// 3 给模特命名</label>
<input className="asset-prompt-edit" style={{ minHeight: 0, height: 36 }} placeholder="如:都市白领 · 小林" value={modelName} onChange={(e) => setModelName(e.target.value)} />
<button className="btn btn-primary" type="button" disabled={busy} style={{ marginTop: 14, width: "100%" }} onClick={() => void saveModel()}>
{busy ? "保存中…" : "保存模特"}
</button>
</div>
) : (
<div className="placeholder" style={{ minHeight: 220, flexDirection: "column", gap: 10 }}>
<span className="ph-frame">// {studioMode === "ai" ? "生成候选后,选一张进入这里" : "上传图片后,在这里命名并保存模特"}</span>
</div>
)}
</div>
</div>
</div>
) : (
<div className="actorlib-body">
<div className="actorlib-toolbar">
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// 共 {list.length} 位模特</span>
<span className="spacer" style={{ flex: 1 }}></span>
<button className="btn btn-primary btn-sm" type="button" onClick={() => { setStudio(true); setStudioMode("ai"); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M12 5v14M5 12h14" /></svg>
添加模特
</button>
</div>
{list.length ? (
<>
<div className="actorlib-grid">
{pageList.map((a) => {
const url = previewOf(a);
return (
<div className="actor-card" key={a.id}>
<div className={`placeholder actor-thumb${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}
role="button" tabIndex={0} title={mode === "replace" ? "选用此模特" : "查看大图"}
onClick={() => { if (mode === "replace") { void onPick?.(a.id, a.name); } else if (url) { setPreview({ src: url, name: a.name }); } }}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (mode === "replace") { void onPick?.(a.id, a.name); } else if (url) { setPreview({ src: url, name: a.name }); } } }}>
{!url && <span className="ph-frame">{a.name}</span>}
{mode === "replace" && <span className="actor-pick">选用</span>}
</div>
<div className="actor-name" title={a.name}>{a.name}</div>
</div>
);
})}
</div>
{pageCount > 1 && (
<div className="actorlib-pager" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, marginTop: 16 }}>
<button className="btn btn-ghost btn-sm" type="button" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>上一页</button>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>{page} / {pageCount}</span>
<button className="btn btn-ghost btn-sm" type="button" disabled={page >= pageCount} onClick={() => setPage((p) => Math.min(pageCount, p + 1))}>下一页</button>
</div>
)}
</>
) : (
<div className="placeholder" style={{ minHeight: 160, flexDirection: "column", gap: 10 }}>
<span className="ph-frame">// 模特库暂无模特 · 点右上“添加模特”创建</span>
</div>
)}
</div>
)}
</div>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
</div>,
document.body,
);
}