417 lines
19 KiB
TypeScript
417 lines
19 KiB
TypeScript
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>
|
||
);
|
||
}
|