feat: 接通模特三视图生成

This commit is contained in:
hh
2026-07-13 11:22:37 +08:00
parent fc0f0ca370
commit 6a37d2e9df
5 changed files with 118 additions and 7 deletions
+89 -5
View File
@@ -18,14 +18,20 @@ const TABS: { k: Tab; label: string }[] = [
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 }: {
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("");
@@ -33,7 +39,11 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify }: {
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);
@@ -48,12 +58,25 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify }: {
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);
}, []);
@@ -61,6 +84,14 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify }: {
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;
@@ -95,6 +126,51 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify }: {
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()}>
@@ -144,8 +220,9 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify }: {
<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 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>
@@ -158,8 +235,11 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify }: {
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>}
{!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">
@@ -187,7 +267,10 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify }: {
// 模特库:顶级实体(团队级可复用)= 形象图 + 三视图 + 声线(尾期)。官方预制打「官方模板」标签。
// 图片创作(模特上身图)与视频项目(角色)都引用它。期1:看归好的成套模特 + 真人上传 + 删自建。
export function ModelsPage({ onNotify }: { onNotify?: (type: "success" | "error", text: string) => void }) {
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);
@@ -325,6 +408,7 @@ export function ModelsPage({ onNotify }: { onNotify?: (type: "success" | "error"
setDetail(updated);
}}
onNotify={onNotify}
onBillingChanged={onBillingChanged}
/>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
</div>