Files
yingqing/core/frontend/src/routes/models.tsx
T
2026-07-13 11:22:37 +08:00

417 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { RefreshCw, Upload, User, X } from "lucide-react";
import { api } from "../api";
import type { ModelEntity } from "../types";
import { MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import "../models-page.css";
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
type Tab = "all" | "official" | "mine";
const TABS: { k: Tab; label: string }[] = [
{ k: "all", label: "全部" },
{ k: "official", label: "官方模板" },
{ k: "mine", label: "我的模特" }
];
type Preview = { src: string; kind: "image"; name: string };
const formatPoints = (value: string) => {
const points = Number(value);
return Number.isFinite(points) ? String(points) : value;
};
// 模特详情弹窗:大图形象图 + 名字 + 官方模板/来源标签 + 三视图(16:9 单容器,无则占位)。
// 复用 overlays.tsx 的 .modal 家族机制(useBodyScrollLock + useOverlayTransition + createPortal)。
function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingChanged }: {
model: ModelEntity | null;
close: () => void;
onZoom: (p: Preview) => void;
onSaved: (model: ModelEntity) => void;
onNotify?: (type: "success" | "error", text: string) => void;
onBillingChanged?: () => 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 [triviewPrice, setTriviewPrice] = useState("");
const [triviewBusy, setTriviewBusy] = useState(false);
const [triviewStatus, setTriviewStatus] = useState("");
const pendingPortraitUrlRef = useRef("");
const triviewPollRef = useRef(0);
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, saving ? undefined : dismiss);
useEffect(() => {
triviewPollRef.current += 1;
clearPendingPortrait();
if (!model) return;
setName(model.name);
setDescription(model.description || "");
setSaving(false);
setTriviewBusy(false);
setTriviewStatus("");
}, [model?.id]);
useEffect(() => {
let alive = true;
setTriviewPrice("");
if (model && !model.is_official) {
api.modelTriviewQuote(model.id)
.then((result) => { if (alive) setTriviewPrice(formatPoints(result.points)); })
.catch(() => { if (alive) setTriviewPrice(""); });
}
return () => { alive = false; };
}, [model?.id, model?.is_official]);
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 canGenerateTriview = Boolean(model.portrait) && Boolean(triviewPrice) && !isDirty && !saving && !triviewBusy;
const triviewTitle = !model.portrait
? "请先设置模特形象图"
: isDirty
? "请先保存模特修改"
: !triviewPrice
? "正在读取当前价格"
: undefined;
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);
}
async function generateTriview() {
if (!canGenerateTriview || currentModel.is_official) return;
const pollToken = triviewPollRef.current + 1;
triviewPollRef.current = pollToken;
setTriviewBusy(true);
setTriviewStatus("正在提交生成任务…");
try {
const submitted = await api.generateModelTriview(currentModel.id);
if (triviewPollRef.current !== pollToken) return;
onBillingChanged?.();
setTriviewStatus(submitted.reused ? "已有任务生成中…" : "三视图生成中…");
let transientFailures = 0;
for (;;) {
await new Promise((resolve) => window.setTimeout(resolve, 1500));
if (triviewPollRef.current !== pollToken) return;
try {
const result = await api.modelTriviewStatus(currentModel.id, submitted.task_id);
transientFailures = 0;
if (result.status === "succeeded" && result.model) {
onSaved(result.model);
onBillingChanged?.();
setTriviewStatus("");
onNotify?.("success", `三视图已生成,扣除 ${result.actual_cost || submitted.estimated_cost} 积分`);
return;
}
if (["failed", "cancelled"].includes(result.status)) {
onBillingChanged?.();
setTriviewStatus("");
onNotify?.("error", result.error_message || "三视图生成失败,预留积分已释放");
return;
}
} catch (error) {
transientFailures += 1;
if (transientFailures >= 3) throw error;
}
}
} catch (error) {
if (triviewPollRef.current === pollToken) {
setTriviewStatus("");
onNotify?.("error", error instanceof Error ? error.message : "三视图生成失败");
}
} finally {
if (triviewPollRef.current === pollToken) setTriviewBusy(false);
}
}
return createPortal(
<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">
模特详情
<span>模特 · {model.name}</span>
</div>
<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="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-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={!canGenerateTriview} title={triviewTitle} onClick={() => void generateTriview()}>
{triviewBusy ? <span className="spinner btn-spin" aria-hidden="true" /> : <RefreshCw size={13} />}
{triviewBusy ? "三视图生成中…" : `${model.triview ? "重新生成" : "生成三视图"} · ${triviewPrice || "--"} 积分`}
</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 && (triviewBusy
? <span className="ph-frame mono"><span className="spinner" aria-hidden="true" /> 三视图生成中…</span>
: <span className="ph-frame mono">// 暂无三视图 · 模特仍可正常使用</span>)}
</div>
{triviewStatus && <div className="field-hint mono">// {triviewStatus}</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,
);
}
// 模特库:顶级实体(团队级可复用)= 形象图 + 三视图 + 声线(尾期)。官方预制打「官方模板」标签。
// 图片创作(模特上身图)与视频项目(角色)都引用它。期1:看归好的成套模特 + 真人上传 + 删自建。
export function ModelsPage({ onNotify, onBillingChanged }: {
onNotify?: (type: "success" | "error", text: string) => void;
onBillingChanged?: () => void;
}) {
const [tab, setTab] = useState<Tab>("all");
const [items, setItems] = useState<ModelEntity[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [confirmId, setConfirmId] = useState<string | null>(null);
const [detail, setDetail] = useState<ModelEntity | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const fileRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
let alive = true;
setLoading(true);
api
.listModels({ tab: tab === "all" ? undefined : tab })
.then((r) => { if (alive) setItems(r.results); })
.catch(() => { if (alive) setItems([]); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [tab]);
async function onPick(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setUploading(true);
try {
const form = new FormData();
form.append("file", file);
form.append("name", file.name.replace(/\.[^.]+$/, ""));
const created = await api.uploadModel(form);
setItems((list) => (tab === "official" ? list : [created, ...list]));
} finally {
setUploading(false);
}
}
async function remove(id: string) {
try {
await api.deleteModel(id);
setItems((list) => list.filter((m) => m.id !== id));
onNotify?.("success", "已移至垃圾桶");
} catch (error) {
onNotify?.("error", error instanceof Error ? error.message : "删除失败");
} finally {
setConfirmId(null);
}
}
return (
<div className="models-page">
<div className="page-head">
<div>
<h1>模特库</h1>
<div className="sub">
<span className="mono">// 团队级可复用 · 形象图 + 三视图</span>
<span>·</span>
<span>图片创作与视频项目都能引用</span>
</div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" disabled={uploading} onClick={() => fileRef.current?.click()}>
{uploading ? "上传中…" : "真人上传"}
</button>
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onPick} />
</div>
</div>
<div className="tabs">
{TABS.map((t) => (
<button key={t.k} type="button" className={`tab${tab === t.k ? " active" : ""}`} onClick={() => setTab(t.k)}>
{t.label}
</button>
))}
</div>
{loading ? (
<div className="placeholder" style={{ minHeight: 200 }}><span className="ph-frame">// 加载中…</span></div>
) : items.length === 0 ? (
<div className="placeholder models-empty">
<span className="ph-frame">// 还没有模特</span>
<span className="models-empty-hint mono">点右上「真人上传」加一个,或在图片/视频流程里生成</span>
</div>
) : (
<div className="models-grid">
{items.map((m) => (
<div
className="model-card"
key={m.id}
role="button"
tabIndex={0}
onClick={() => setDetail(m)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setDetail(m); } }}
>
<div
className={`placeholder model-portrait${m.portrait ? " has-mock-media" : ""}`}
style={m.portrait ? mediaStyle(m.portrait) : undefined}
>
{!m.portrait && <span className="ph-frame">无图</span>}
{m.is_official && <span className="pill info model-official">官方模板</span>}
{!m.is_official &&
(confirmId === m.id ? (
<div className="model-confirm" onClick={(e) => e.stopPropagation()}>
<button className="btn btn-sm model-del" type="button" onClick={() => void remove(m.id)}>删除</button>
<button className="btn btn-sm" type="button" onClick={() => setConfirmId(null)}>取消</button>
</div>
) : (
<button className="model-del-btn" type="button" title="删除模特" onClick={(e) => { e.stopPropagation(); setConfirmId(m.id); }}>×</button>
))}
</div>
<div className="model-body">
<div className="model-name" title={m.name}>{m.name}</div>
<div
className={`placeholder model-triview${m.triview ? " has-mock-media" : ""}`}
style={m.triview ? mediaStyle(m.triview) : undefined}
>
{!m.triview && <span className="ph-frame mono">// 无三视图</span>}
</div>
<div className="model-tags mono">
<span>{m.source === "upload" ? "真人上传" : "AI 生成"}</span>
<span>·</span>
<span>{m.triview ? "三视图 ✓" : "三视图 —"}</span>
</div>
</div>
</div>
))}
</div>
)}
<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}
onBillingChanged={onBillingChanged}
/>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
</div>
);
}