From 6a37d2e9df6ff992d3e211f208651ffd55521612 Mon Sep 17 00:00:00 2001 From: hh <2587203630@qq.com> Date: Mon, 13 Jul 2026 11:22:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E9=80=9A=E6=A8=A1=E7=89=B9?= =?UTF-8?q?=E4=B8=89=E8=A7=86=E5=9B=BE=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + core/frontend/src/App.tsx | 7 ++- core/frontend/src/api.ts | 20 ++++++ core/frontend/src/models-page.css | 3 +- core/frontend/src/routes/models.tsx | 94 +++++++++++++++++++++++++++-- 5 files changed, 118 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8360273..db3ed68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,7 @@ - **严禁 `--no-verify` 跳过 hook** - **Push 规则:** 默认不 push,改完即停 · 用户明确说"push / 推一下"才执行 - **commit 前不要 amend** — 创建新 commit,避免破坏历史 +- **Commit 格式:** 使用 `type: 中文简述`,例如 `feat: 接通模特三视图生成`;类型按改动选择 `feat`、`fix`、`docs`、`refactor`、`test`、`chore` 等,冒号后的备注必须是精简中文 ## 文件操作 diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 83bc093..fff0977 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -833,7 +833,12 @@ export function App() { ); case "models": - return setNotice({ type, text })} />; + return ( + setNotice({ type, text })} + onBillingChanged={() => { api.billingSummary().then((summary) => setBilling(summary)).catch(() => {}); }} + /> + ); case "library": return action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />; case "trash": diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 4557433..a961bc7 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -617,6 +617,26 @@ export const api = { updateLibraryModel(id: string, payload: { name?: string; description?: string }) { return request(`/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(`/api/models/${id}/`, { method: "DELETE" }); diff --git a/core/frontend/src/models-page.css b/core/frontend/src/models-page.css index 787cb7c..7cb8295 100644 --- a/core/frontend/src/models-page.css +++ b/core/frontend/src/models-page.css @@ -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 { diff --git a/core/frontend/src/routes/models.tsx b/core/frontend/src/routes/models.tsx index 83c37e5..a20428f 100644 --- a/core/frontend/src/routes/models.tsx +++ b/core/frontend/src/routes/models.tsx @@ -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(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); @@ -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(
{ if (!saving) dismiss(); }}>
event.stopPropagation()}> @@ -144,8 +220,9 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify }: {
// 可选资产 {!model.is_official && ( - )}
@@ -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 && // 暂无三视图 · 模特仍可正常使用} + {!model.triview && (triviewBusy + ? + : // 暂无三视图 · 模特仍可正常使用)}
+ {triviewStatus &&
// {triviewStatus}
}
@@ -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("all"); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); @@ -325,6 +408,7 @@ export function ModelsPage({ onNotify }: { onNotify?: (type: "success" | "error" setDetail(updated); }} onNotify={onNotify} + onBillingChanged={onBillingChanged} /> setPreview(null)} />