feat: 解耦角色与模特并完善模特库

This commit is contained in:
hh
2026-07-13 11:02:30 +08:00
parent 564215dc53
commit fc0f0ca370
22 changed files with 1328 additions and 314 deletions
+154 -32
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { User, X } from "lucide-react";
import { RefreshCw, Upload, User, X } from "lucide-react";
import { api } from "../api";
import type { ModelEntity } from "../types";
import { MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
@@ -20,52 +20,165 @@ type Preview = { src: string; kind: "image"; name: string };
// 模特详情弹窗:大图形象图 + 名字 + 官方模板/来源标签 + 三视图(16:9 单容器,无则占位)。
// 复用 overlays.tsx 的 .modal 家族机制(useBodyScrollLock + useOverlayTransition + createPortal)。
function ModelDetailModal({ model, close, onZoom }: { model: ModelEntity | null; close: () => void; onZoom: (p: Preview) => void }) {
function ModelDetailModal({ model, close, onZoom, onSaved, onNotify }: {
model: ModelEntity | null;
close: () => void;
onZoom: (p: Preview) => void;
onSaved: (model: ModelEntity) => void;
onNotify?: (type: "success" | "error", text: string) => void;
}) {
const open = !!model;
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false);
const [pendingPortrait, setPendingPortrait] = useState<File | null>(null);
const [pendingPortraitUrl, setPendingPortraitUrl] = useState("");
const pendingPortraitUrlRef = useRef("");
const portraitFileRef = useRef<HTMLInputElement | null>(null);
function clearPendingPortrait() {
if (pendingPortraitUrlRef.current) URL.revokeObjectURL(pendingPortraitUrlRef.current);
pendingPortraitUrlRef.current = "";
setPendingPortrait(null);
setPendingPortraitUrl("");
}
function dismiss() {
clearPendingPortrait();
close();
}
useBodyScrollLock(open);
const { mounted, show } = useOverlayTransition(open, close);
const { mounted, show } = useOverlayTransition(open, saving ? undefined : dismiss);
useEffect(() => {
clearPendingPortrait();
if (!model) return;
setName(model.name);
setDescription(model.description || "");
setSaving(false);
}, [model?.id]);
useEffect(() => () => {
if (pendingPortraitUrlRef.current) URL.revokeObjectURL(pendingPortraitUrlRef.current);
}, []);
if (!mounted || !model) return null;
const currentModel = model;
const portraitUrl = pendingPortraitUrl || model.portrait;
const isDirty = Boolean(pendingPortrait) || name.trim() !== model.name || description.trim() !== (model.description || "");
const zoomKey = (p: Preview) => (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onZoom(p); } };
async function save() {
if (currentModel.is_official || !name.trim() || saving) return;
setSaving(true);
try {
let updated: ModelEntity;
if (pendingPortrait) {
const form = new FormData();
form.append("file", pendingPortrait);
form.append("name", name.trim());
form.append("description", description.trim());
updated = await api.uploadModelPortrait(currentModel.id, form);
} else {
updated = await api.updateLibraryModel(currentModel.id, { name: name.trim(), description: description.trim() });
}
onSaved(updated);
clearPendingPortrait();
setName(updated.name);
setDescription(updated.description || "");
onNotify?.("success", "模特资料已保存");
} catch (error) {
onNotify?.("error", error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
}
function selectPortrait(file?: File) {
if (!file || currentModel.is_official || saving) return;
clearPendingPortrait();
const url = URL.createObjectURL(file);
pendingPortraitUrlRef.current = url;
setPendingPortrait(file);
setPendingPortraitUrl(url);
}
return createPortal(
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
<div className={`modal-bg${show ? " show" : ""}`} onClick={() => { if (!saving) dismiss(); }}>
<div className="modal model-detail-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><User size={16} /></div>
<div className="ti">
{model.name}
<span>{model.is_official ? "官方模板" : model.source === "upload" ? "真人上传" : "AI 生成"}</span>
模特详情
<span>模特 · {model.name}</span>
</div>
<button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button>
<button className="x modal-x" type="button" disabled={saving} onClick={dismiss} aria-label="关闭"><X size={14} /></button>
</div>
<div className="modal-b model-detail-b">
<div
className={`placeholder model-detail-portrait${model.portrait ? " has-mock-media" : ""}`}
style={model.portrait ? mediaStyle(model.portrait) : undefined}
role={model.portrait ? "button" : undefined}
tabIndex={model.portrait ? 0 : undefined}
onClick={model.portrait ? () => onZoom({ src: model.portrait, kind: "image", name: model.name }) : undefined}
onKeyDown={model.portrait ? zoomKey({ src: model.portrait, kind: "image", name: model.name }) : undefined}
>
{!model.portrait && <span className="ph-frame">无图</span>}
<div className="model-detail-left">
<div className="model-detail-section-h">
<strong>形象图</strong>
<div className="model-detail-section-actions">
<span className="mono">// PORTRAIT</span>
{!model.is_official && (
<button className="btn btn-sm" type="button" disabled={saving} onClick={() => portraitFileRef.current?.click()}>
<Upload size={13} />{pendingPortrait ? "重新选择" : model.portrait ? "替换形象图" : "上传形象图"}
</button>
)}
</div>
</div>
<input ref={portraitFileRef} type="file" accept="image/*" hidden onChange={(event) => { const file = event.target.files?.[0]; event.target.value = ""; selectPortrait(file); }} />
<div
className={`placeholder model-detail-portrait${portraitUrl ? " has-mock-media" : ""}`}
style={portraitUrl ? mediaStyle(portraitUrl) : undefined}
role={portraitUrl ? "button" : undefined}
tabIndex={portraitUrl ? 0 : undefined}
onClick={portraitUrl ? () => onZoom({ src: portraitUrl, kind: "image", name: pendingPortrait ? `${name || model.name} · 待保存形象图` : model.name }) : undefined}
onKeyDown={portraitUrl ? zoomKey({ src: portraitUrl, kind: "image", name: pendingPortrait ? `${name || model.name} · 待保存形象图` : model.name }) : undefined}
>
{!portraitUrl && <span className="ph-frame">无图</span>}
</div>
<div className="model-detail-tags mono">
{model.is_official && <span className="pill info">官方模板</span>}
<span className="pill neutral">{model.source === "upload" ? "真人上传" : "AI 生成"}</span>
{pendingPortrait && <span className="pill info">待保存</span>}
</div>
</div>
<div className="model-detail-tags mono">
{model.is_official && <span className="pill info">官方模板</span>}
<span className="pill neutral">{model.source === "upload" ? "真人上传" : "AI 生成"}</span>
</div>
{model.description && <p className="model-detail-desc">{model.description}</p>}
<div className="model-detail-label mono">// 三视图</div>
<div
className={`placeholder model-detail-triview${model.triview ? " has-mock-media" : ""}`}
style={model.triview ? mediaStyle(model.triview) : undefined}
role={model.triview ? "button" : undefined}
tabIndex={model.triview ? 0 : undefined}
onClick={model.triview ? () => onZoom({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
onKeyDown={model.triview ? zoomKey({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
>
{!model.triview && <span className="ph-frame mono">// 无三视图</span>}
<div className="model-detail-right">
<div>
<div className="model-detail-section-h">
<strong>三视图</strong>
<div className="model-detail-section-actions">
<span className="mono">// 可选资产</span>
{!model.is_official && (
<button className="btn btn-sm" type="button" disabled title={model.portrait ? "真实生成与计费将在下一步接通" : "请先设置模特形象图"}>
<RefreshCw size={13} />{model.triview ? "重新生成 · 20 积分" : "生成三视图 · 20 积分"}
</button>
)}
</div>
</div>
<div
className={`placeholder model-detail-triview${model.triview ? " has-mock-media" : ""}`}
style={model.triview ? mediaStyle(model.triview) : undefined}
role={model.triview ? "button" : undefined}
tabIndex={model.triview ? 0 : undefined}
onClick={model.triview ? () => onZoom({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
onKeyDown={model.triview ? zoomKey({ src: model.triview, kind: "image", name: `${model.name} · 三视图` }) : undefined}
>
{!model.triview && <span className="ph-frame mono">// 暂无三视图 · 模特仍可正常使用</span>}
</div>
</div>
<div className="model-detail-form">
<div className="field">
<label className="field-label" htmlFor="model-edit-name">模特名称</label>
<input id="model-edit-name" className="input" maxLength={255} disabled={model.is_official || saving} value={name} onChange={(event) => setName(event.target.value)} />
</div>
<div className="field">
<label className="field-label" htmlFor="model-edit-description">模特描述 / 提示词</label>
<textarea id="model-edit-description" className="textarea" rows={4} disabled={model.is_official || saving} placeholder="补充模特特征、风格或生成提示词" value={description} onChange={(event) => setDescription(event.target.value)} />
</div>
<div className="field-hint mono">// 编辑模特资料不会回写已存在的视频项目角色</div>
</div>
</div>
</div>
<div className="modal-f">
{model.is_official && <span className="model-detail-readonly mono">// 官方模板仅供查看</span>}
<button className="btn" type="button" disabled={saving} onClick={dismiss}>关闭</button>
{!model.is_official && <button className="btn btn-primary" type="button" disabled={saving || !name.trim() || !isDirty} onClick={() => void save()}>{saving ? "保存中…" : "保存修改"}</button>}
</div>
</div>
</div>,
document.body,
@@ -203,7 +316,16 @@ export function ModelsPage({ onNotify }: { onNotify?: (type: "success" | "error"
</div>
)}
<ModelDetailModal model={detail} close={() => setDetail(null)} onZoom={setPreview} />
<ModelDetailModal
model={detail}
close={() => setDetail(null)}
onZoom={setPreview}
onSaved={(updated) => {
setItems((list) => list.map((item) => item.id === updated.id ? updated : item));
setDetail(updated);
}}
onNotify={onNotify}
/>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
</div>
);