|
|
|
@@ -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>
|
|
|
|
|