feat: 解耦角色与模特并完善模特库
This commit is contained in:
+46
-118
@@ -5,52 +5,43 @@ import { api } from "../api";
|
||||
import type { Asset, ModelEntity } from "../types";
|
||||
import { useBodyScrollLock, MediaLightbox } from "./overlays";
|
||||
|
||||
// 流程步骤4 · 演员库(期3 收编):角色可「引用模特库」—— 顶部「模特库」tab 取自模特库实体(含官方跨团队模特),
|
||||
// 选一个即用其形象图作该角色的参考/回填;「我的演员」= 本项目未入库的 person 资产。
|
||||
// 两种用法:browse(纯浏览 / 添加演员)与 replace(从库里选一个回填到某基础资产卡)。
|
||||
// 模特选择器:只读取 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,选中即作角色参考图);标 kind=model 归「模特库」tab
|
||||
// 模特库实体 → 供现有卡片复用的 Asset 形;id 保持形象资产 id,选中后可直接作为参考图。
|
||||
const modelToAsset = (m: ModelEntity): Asset => ({
|
||||
id: m.portrait_asset as string,
|
||||
name: m.name,
|
||||
asset_type: "image",
|
||||
source: "ai_generated",
|
||||
category: "person",
|
||||
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
|
||||
});
|
||||
// 模特库 = 模特库实体(metadata.kind==="model");其余 person 资产归「我的演员」
|
||||
const isPreset = (a: Asset): boolean => (a.metadata?.kind as string) === "model";
|
||||
|
||||
export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPick, onGenerate, onUpload, onGenerateTriview, onRename, onRefresh }: {
|
||||
export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGenerate, onUpload, onRename, onRefresh }: {
|
||||
open: boolean;
|
||||
mode: "browse" | "replace";
|
||||
initialStudio?: boolean; // 打开即进「添加人物工作台」
|
||||
assets: Asset[];
|
||||
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>;
|
||||
// 据选中立绘生成配套三视图(后端吃任意 person 资产 id,不依赖该资产已在某 base group)
|
||||
onGenerateTriview?: (portraitAssetId: string) => Promise<{ id: string } | null>;
|
||||
onRename?: (assetId: string, name: string) => Promise<unknown>; // 给人物命名(写回资产 name)
|
||||
onRename?: (assetId: string, name: string) => Promise<unknown>; // 同步底层形象资产名称
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"preset" | "mine">("preset");
|
||||
const [studio, setStudio] = useState(Boolean(initialStudio)); // 添加演员工作台
|
||||
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 [actorName, setActorName] = useState("");
|
||||
const [triReady, setTriReady] = useState(false); // 已据选中立绘生成过三视图
|
||||
const [modelName, setModelName] = useState("");
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
useBodyScrollLock(open);
|
||||
useEffect(() => {
|
||||
@@ -64,46 +55,32 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
if (!open) return;
|
||||
setStudio(Boolean(initialStudio));
|
||||
setStudioMode("ai");
|
||||
setCandidates([]); setPicked(null); setActorName(""); setTriReady(false);
|
||||
setCandidates([]); setPicked(null); setModelName("");
|
||||
}, [open, initialStudio]);
|
||||
|
||||
// 演员库自取 person 资产:全局 assets 已不再全量预载(按需懒加载),不能再依赖传入的 assets prop,
|
||||
// 否则平台预设/我的演员全空。打开时拉一页(团队 person 资产),保存后 reload 让新人物即时入列。
|
||||
// 模特选择器只拉 Model;不再把普通 person 资产并入可选列表。
|
||||
const [fetched, setFetched] = useState<Asset[]>([]);
|
||||
const reload = useCallback(async () => {
|
||||
// 期3:同时拉「项目 person 资产」+「模特库实体」。模特放后面 → 按 id 去重时模特胜出,
|
||||
// 同一张形象图(既是 person 资产又被模特库引用)归「模特库」tab,可作角色参考。
|
||||
const [persons, models] = await Promise.all([
|
||||
api.assetsPage({ category: "person", pageSize: 200, ordering: "-created_at" }).catch(() => null),
|
||||
api.listModels({ pageSize: 200 }).catch(() => null)
|
||||
]);
|
||||
const models = await api.listModels({ pageSize: 200 }).catch(() => null);
|
||||
const mapped = (models?.results ?? []).filter((m) => m.portrait_asset).map(modelToAsset);
|
||||
setFetched([...(persons?.results ?? []), ...mapped]);
|
||||
setFetched(mapped);
|
||||
}, []);
|
||||
useEffect(() => { if (open) void reload(); }, [open, reload]);
|
||||
|
||||
// person 类资产 → 演员;按 preset / mine 分两 tab。合并 prop(兼容旧调用)+ 自取,按 id 去重。
|
||||
const source = useMemo(() => {
|
||||
const map = new Map<string, Asset>();
|
||||
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)));
|
||||
const list = useMemo(() => fetched.filter((a) => previewOf(a)), [fetched]);
|
||||
|
||||
// 演员库分页(客户端):一页 10 个(5 列 × 2 行)。切 tab / 列表变化时回第 1 页,避免停在越界页。
|
||||
// 模特库分页(客户端):一页 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); }, [tab, open]);
|
||||
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);
|
||||
setActorName(a.name || "");
|
||||
setTriReady(false);
|
||||
setModelName(a.name || "");
|
||||
}
|
||||
// AI 生成立绘候选(不直接关 studio,等用户选一张进下一步)
|
||||
async function genCandidates() {
|
||||
@@ -125,55 +102,28 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
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() {
|
||||
// 保存模特:同步名称 → 复用当前形象资产创建 Model → 刷新模特库。
|
||||
async function saveModel() {
|
||||
if (!picked) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const name = actorName.trim();
|
||||
const name = modelName.trim();
|
||||
if (name && name !== picked.name && onRename) await onRename(picked.id, name);
|
||||
// 关键:AI 生成/上传得到的立绘默认 in_library=False(工作台暂存),不入「我的演员」列表(只列已入库 person)。
|
||||
// 「保存人物」必须把它加入资产库,否则保存后「我的演员」始终 0 个演员(ZWQ#6)。
|
||||
await api.setAssetLibrary(picked.id, true).catch(() => undefined);
|
||||
await api.enrollModelFromAsset(picked.id, name || undefined);
|
||||
onRefresh();
|
||||
await reload(); // 新保存的人物即时进「我的演员」列表(自取,不依赖父级全局 assets)
|
||||
await reload(); // 新建模特即时进入唯一的模特列表
|
||||
setStudio(false);
|
||||
setCandidates([]); setPicked(null); setActorName(""); setTriReady(false);
|
||||
setTab("mine");
|
||||
setCandidates([]); setPicked(null); setModelName("");
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
// 删除「我的演员」(软删 person 资产)· 两步确认,3s 自动撤销(ZWQ#8)
|
||||
const [delArmId, setDelArmId] = useState<string | null>(null);
|
||||
const [delBusyId, setDelBusyId] = useState<string | null>(null);
|
||||
async function deleteActor(id: string) {
|
||||
setDelBusyId(id);
|
||||
try {
|
||||
await api.deleteAsset(id);
|
||||
onRefresh();
|
||||
await reload();
|
||||
} catch { /* 删除失败保留卡片,可重试 */ } finally {
|
||||
setDelBusyId(null);
|
||||
setDelArmId(null);
|
||||
}
|
||||
}
|
||||
|
||||
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" 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>
|
||||
<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>
|
||||
@@ -184,20 +134,20 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
<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>
|
||||
<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>
|
||||
<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 ? "生成中…" : "生成立绘"}
|
||||
@@ -205,10 +155,10 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
</div>
|
||||
) : (
|
||||
<div className="actorlib-studio-upload">
|
||||
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 1 上传本地人物图片 → 进右侧栏生成三视图 / 命名</div>
|
||||
<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 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 = ""; }} />
|
||||
@@ -216,7 +166,7 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
)}
|
||||
{candidates.length > 0 && (
|
||||
<>
|
||||
<div className="muted mono" style={{ fontSize: 12, margin: "14px 0 6px", letterSpacing: ".04em" }}>// 2 选一张立绘 → 右侧命名 / 生成三视图 / 保存</div>
|
||||
<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);
|
||||
@@ -242,21 +192,15 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
<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={actorName} onChange={(e) => setActorName(e.target.value)} />
|
||||
{onGenerateTriview && (
|
||||
<button className="btn btn-ghost btn-sm" type="button" disabled={busy} style={{ marginTop: 12 }} onClick={() => void genTriview()}>
|
||||
{busy ? "提交中…" : triReady ? "已提交三视图 · 再生成一版" : "生成三视图"}
|
||||
</button>
|
||||
)}
|
||||
{triReady && <div className="muted mono" style={{ fontSize: 12, marginTop: 6 }}>// 三视图出图在后台进行,保存后回人物卡详情可查看</div>}
|
||||
<button className="btn btn-primary" type="button" disabled={busy} style={{ marginTop: 14, width: "100%" }} onClick={() => void saveActor()}>
|
||||
{busy ? "保存中…" : "保存人物"}
|
||||
<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>
|
||||
<span className="ph-frame">// {studioMode === "ai" ? "生成候选后,选一张进入这里" : "上传图片后,在这里命名并保存模特"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -265,14 +209,11 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
) : (
|
||||
<div className="actorlib-body">
|
||||
<div className="actorlib-toolbar">
|
||||
<div className="actorlib-tabs">
|
||||
<button className={`al-tab${tab === "preset" ? " active" : ""}`} type="button" onClick={() => setTab("preset")}>模特库 · {people.filter(isPreset).length}</button>
|
||||
<button className={`al-tab${tab === "mine" ? " active" : ""}`} type="button" onClick={() => setTab("mine")}>我的演员 · {people.filter((a) => !isPreset(a)).length}</button>
|
||||
</div>
|
||||
<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 ? (
|
||||
@@ -280,23 +221,10 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
<div className="actorlib-grid">
|
||||
{pageList.map((a) => {
|
||||
const url = previewOf(a);
|
||||
// 「我的演员」(非模特库预设)可删:右上角垃圾桶,两步确认(ZWQ#8)
|
||||
const canDelete = tab === "mine" && !isPreset(a);
|
||||
return (
|
||||
<div className="actor-card" key={a.id} style={{ position: "relative" }}>
|
||||
{canDelete && (delArmId === a.id ? (
|
||||
<span className="asset-card-del-confirm" data-stop style={{ position: "absolute", top: 6, right: 6, zIndex: 3, display: "flex", gap: 4 }}>
|
||||
<button type="button" className="del-yes" disabled={delBusyId === a.id} onClick={(e) => { e.stopPropagation(); void deleteActor(a.id); }}>{delBusyId === a.id ? "删除中…" : "确认删除"}</button>
|
||||
<button type="button" className="del-no" onClick={(e) => { e.stopPropagation(); setDelArmId(null); }}>取消</button>
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className="asset-card-del" title="删除该演员" style={{ position: "absolute", top: 6, right: 6, zIndex: 3 }}
|
||||
onClick={(e) => { e.stopPropagation(); setDelArmId(a.id); window.setTimeout(() => setDelArmId((v) => (v === a.id ? null : v)), 3000); }}>
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" 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>
|
||||
))}
|
||||
<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" ? "选用此演员" : "查看大图"}
|
||||
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>}
|
||||
@@ -317,7 +245,7 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
</>
|
||||
) : (
|
||||
<div className="placeholder" style={{ minHeight: 160, flexDirection: "column", gap: 10 }}>
|
||||
<span className="ph-frame">// {tab === "preset" ? "模特库暂无模特 · 去「模特库」页添加" : "还没有自己的演员 · 点右上「添加演员」"}</span>
|
||||
<span className="ph-frame">// 模特库暂无模特 · 点右上“添加模特”创建</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
Reference in New Issue
Block a user