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
+6 -1
View File
@@ -833,7 +833,12 @@ export function App() {
</div>
);
case "models":
return <ModelsPage onNotify={(type, text) => setNotice({ type, text })} />;
return (
<ModelsPage
onNotify={(type, text) => setNotice({ type, text })}
onBillingChanged={() => { api.billingSummary().then((summary) => setBilling(summary)).catch(() => {}); }}
/>
);
case "library":
return <LibraryPage onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />;
case "trash":
+20
View File
@@ -617,6 +617,26 @@ export const api = {
updateLibraryModel(id: string, payload: { name?: string; description?: string }) {
return request<ModelEntity>(`/api/models/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
modelTriviewQuote(id: string) {
return request<{ points: string }>(`/api/models/${id}/triview-quote/`);
},
generateModelTriview(id: string) {
return request<{ task_id: string; status: string; estimated_cost: string; reused: boolean }>(
`/api/models/${id}/generate-triview/`,
{ method: "POST" },
);
},
modelTriviewStatus(id: string, taskId: string) {
const query = new URLSearchParams({ task_id: taskId });
return request<{
task_id: string;
status: string;
estimated_cost: string;
actual_cost: string;
error_message: string;
model: ModelEntity | null;
}>(`/api/models/${id}/triview-status/?${query.toString()}`);
},
// 删除模特(软删;官方模特不可删)
deleteModel(id: string) {
return request<void>(`/api/models/${id}/`, { method: "DELETE" });
+2 -1
View File
@@ -113,7 +113,7 @@
letter-spacing: .02em;
}
/* 模特详情弹窗:参考视频项目角色详情结构;当前小步只开放名称/描述编辑 */
/* 模特详情弹窗:参考视频项目角色详情结构 */
.modal.model-detail-modal {
width: min(880px, calc(100vw - 80px));
max-width: 880px;
@@ -138,6 +138,7 @@
.model-detail-section-h strong { font-size: 13px; font-weight: 500; color: var(--accent-black); }
.model-detail-section-h .mono { font-size: 11px; color: var(--black-alpha-48); letter-spacing: .04em; }
.model-detail-section-actions { display: flex; align-items: center; gap: 8px; }
.model-detail-section-actions .spinner { width: 13px; height: 13px; border-width: 2px; flex: 0 0 auto; }
/* 形象图大图:3:4 竖版,与卡片缩图比例一致 */
.model-detail-portrait {
+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>