feat: 接通模特三视图生成
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user