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(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(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(
{ if (!saving) dismiss(); }}>
event.stopPropagation()}> ++
模特详情 模特 · {model.name}
形象图
// PORTRAIT {!model.is_official && ( )}
{ const file = event.target.files?.[0]; event.target.value = ""; selectPortrait(file); }} />
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 && 无图}
{model.is_official && 官方模板} {model.source === "upload" ? "真人上传" : "AI 生成"} {pendingPortrait && 待保存}
三视图
// 可选资产 {!model.is_official && ( )}
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 ? : // 暂无三视图 · 模特仍可正常使用)}
{triviewStatus &&
// {triviewStatus}
}
setName(event.target.value)} />