feat: 解耦角色与模特并完善模特库
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user