From 95cd4abd3cebaf22a819e9f8b547a4653f2612e3 Mon Sep 17 00:00:00 2001 From: hh <2587203630@qq.com> Date: Thu, 16 Jul 2026 13:40:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=90=8C=E6=AD=A5=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E7=A7=AF=E5=88=86=E4=BD=99=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/frontend/src/App.tsx | 21 +++++++++++++++++---- core/frontend/src/pipeline-page.css | 1 + core/frontend/src/routes/pipeline.tsx | 22 ++++++++++++++++++---- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index f286fd0..1daa197 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -221,6 +221,12 @@ export function App() { if (data) setUnreadCount(data.unread_count); }, []); + // 仅同步顶栏余额:异步 AI 任务在成功结算后调用,避免为一个数字重拉整站数据。 + const refreshBilling = useCallback(async () => { + const summary = await api.billingSummary().catch(() => null); + if (summary) setBilling(summary); + }, []); + // 自由创作自行管理长视频轮询;任务终态后只需同步顶栏依赖的两项全局数据, // 不必像通用 action 一样重拉商品、项目和素材列表。 const refreshFreeCreateShell = useCallback(() => { @@ -413,8 +419,11 @@ export function App() { if (next && next.id === activeProjectIdRef.current) { // 视频生成动辄数分钟,多数 5s 轮次状态没变 —— 内容一致时跳过 setState,避免整棵管线每 5 秒空重渲染。 setProjectDetail((prev) => (prev && JSON.stringify(prev) === JSON.stringify(next) ? prev : next)); + // 仅在本轮在途片段实际完成时同步余额;失败不产生真实扣费,不刷新。 + const charged = active.some((segment) => next.video_segments.find((item) => item.id === segment.id)?.status === "succeeded"); + if (charged) void refreshBilling(); } - }, [activeProjectId]); + }, [activeProjectId, refreshBilling]); // 静默轮询故事板分镜生成(对标 pollVideosQuiet):有 queued/running 的场就驱动后端出图并刷新,不占全局 loading、不弹 toast。 // 这样单场重跑不会把别的场按钮也锁住——生成在后台跑,UI 只按每场自身状态转圈。 @@ -432,8 +441,11 @@ export function App() { const next = await api.project(activeProjectId).catch(() => null); if (next && next.id === activeProjectIdRef.current) { setProjectDetail((prev) => (prev && JSON.stringify(prev) === JSON.stringify(next) ? prev : next)); + // 分镜图成功落库时后端同时结算积分;只在成功终态刷新顶栏余额。 + const charged = active.some((shot) => next.storyboard_shots?.find((item) => item.id === shot.id)?.status === "succeeded"); + if (charged) void refreshBilling(); } - }, [activeProjectId]); + }, [activeProjectId, refreshBilling]); // 静默刷新导出任务状态(进入拼接页 / 导出后回填成片),不弹 toast。 const refreshExport = useCallback(async () => { @@ -679,13 +691,13 @@ export function App() { const { assets, error } = await pollAiTasks([taskId]); // 只刷新当前项目详情(新出的图就在 base_asset_groups 里),不再整页全量 loadData —— // 后者十几个 setState 触发全 PipelinePage 大重渲染,所有背景图被重新赋值 → "每次生成全页图重载"。 - // 余额受出图扣费影响,单独轻量刷新一下即可,不必连带把 products/projects/全部 assets/通知都重拉。 + // 成功出图已完成真实扣费,单独轻量同步顶栏余额即可,不必连带重拉其他全局数据。 await refreshProjectDetail(); - api.billingSummary().then((b) => b && setBilling(b)).catch(() => {}); if (assets.length === 0) { setNotice({ type: "error", text: error || "生成超时,稍后可在资产里查看" }); return null; } + await refreshBilling(); setNotice({ type: "success", text: okText }); return assets[0].id; } @@ -1100,6 +1112,7 @@ export function App() { exportResult={exportResult} onRefreshExport={refreshExport} onRefreshProject={refreshProjectDetail} + onRefreshBilling={refreshBilling} onUploadVideoSegment={(segmentId, file) => action(() => api.uploadVideoSegment(pipelineProject.id, segmentId, file), "视频已上传")} onUploadBgm={(file, volume) => action(() => api.uploadBgm(pipelineProject.id, file, volume), "BGM 已上传")} onSaveTimeline={(payload) => action(() => api.saveTimeline(pipelineProject.id, payload), "草稿已保存")} diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index f8c0600..3f85c75 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -812,6 +812,7 @@ } .eg-title { font-size: 16px; font-weight: 600; color: var(--accent-black); } .eg-sub { font-size: 11px; color: var(--black-alpha-48); margin-top: 6px; } +.eg-cost-hint { font-size: 11px; color: var(--black-alpha-48); margin-top: 8px; } .extract-gate-btns { display: flex; flex-direction: column; gap: 10px; margin-top: 22px; } .eg-btn { display: flex; diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 652a14b..69de39c 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -519,6 +519,7 @@ export function PipelinePage(props: { exportResult: ExportPoll | null; onRefreshExport: () => void; onRefreshProject: () => Promise | void; + onRefreshBilling: () => Promise | void; onUploadVideoSegment: (segmentId: string, file: File) => void; onUploadBgm: (file: File, volume: number) => void; onSaveTimeline: (payload: TimelineSavePayload) => void; @@ -528,7 +529,7 @@ export function PipelinePage(props: { project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout, onNotify, textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover, onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateModel, onUploadModel, onGenerateTriview, onRenameModel, onGenerateStoryboard, onRerunStoryboardShot, onAdoptStoryboardShotVersion, onPollStoryboardQuiet, onSkipStoryboard, - onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject, + onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject, onRefreshBilling, onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport } = props; @@ -775,6 +776,8 @@ export function PipelinePage(props: { // (基础资产页「一直在轮询」的根因之一)。用 ref 稳住,effect 不再因它重跑。 const onRefreshProjectRef = useRef(onRefreshProject); useEffect(() => { onRefreshProjectRef.current = onRefreshProject; }); + const onRefreshBillingRef = useRef(onRefreshBilling); + useEffect(() => { onRefreshBillingRef.current = onRefreshBilling; }); // 审核轮询的「重启信号」:手动送审/一键送审后 +1,让已停的 poll-reviews 重新开跑去盯新提交的审核。 const [reviewWake, setReviewWake] = useState(0); // 行38/流程步骤4 · 单卡生成 loading:按「busyKey」分别记录,各按钮互不影响(生成三视图不挡新增人物) @@ -848,6 +851,7 @@ export function PipelinePage(props: { finish(); if (s.status === "succeeded") { await onRefreshProject(); // 拉回 metadata(cast/scenes/script_entities)+ 回填的每镜 refs + await onRefreshBilling(); // 实体提取成功已产生真实扣费,同步右上角现有余额 if ((mode === "gen" || mode === "full") && (s.entities?.length ?? 0) > 0) { await runGenForEntities(s.entities, mode); } @@ -1508,6 +1512,7 @@ export function PipelinePage(props: { setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true, thinkingElapsedSeconds: m.thinkingElapsedSeconds ?? elapsedSinceStart(m.startedAt), thinkingDurationReady: m.thinkingDurationReady ?? false } : m))); if (ok) { await onRefreshProject(); + await onRefreshBilling(); // SSE 的 saved 事件在后端完成脚本扣费后才发出 // 模型写了收尾就用它(真 agent 感);没写则兜底默认句 pushMsg("ai", summaryText || "镜头脚本已生成,左侧已刷新。可继续输入修改意见,或点「确认脚本」进入下一步。"); } @@ -2505,7 +2510,7 @@ export function PipelinePage(props: { // 流程步骤4 · 在基础资产趴轮询后端在途出图任务,重建「生成中」占位卡 loading(刷新后内存态丢失也能恢复); // 在途数减少=有任务出图完成 → 刷新项目把占位卡换成真卡。离开该趴即停。 - const prevPendingCountRef = useRef(0); + const prevPendingIdsRef = useRef([]); useEffect(() => { if (activeDot !== 2 && viewStage !== 2) return; let stopped = false; @@ -2516,8 +2521,15 @@ export function PipelinePage(props: { const res = await api.pendingAssets(project.id); if (stopped) return; const list = res.pending || []; - if (list.length < prevPendingCountRef.current) void onRefreshProjectRef.current(); - prevPendingCountRef.current = list.length; + const completedIds = prevPendingIdsRef.current.filter((id) => !list.some((pending) => pending.id === id)); + if (completedIds.length > 0) { + void onRefreshProjectRef.current(); + // 刷新后认领的在途出图:先确认终态成功,失败/退款不刷新右上角余额。 + void api.generateImageStatus(completedIds).then((result) => { + if (result.tasks.some((task) => task.status === "succeeded")) void onRefreshBillingRef.current(); + }).catch(() => undefined); + } + prevPendingIdsRef.current = list.map((pending) => pending.id); setPendingGen(list.map((p) => ({ kind: p.kind, label: p.label, is_triview: p.is_triview }))); // 没有在途出图、本地也没有生成在跑 → 没什么可等,停轮询;再点生成(genBusy 变)时本 effect 会重订阅恢复。 if (list.length === 0 && genBusy.size === 0) { stopped = true; return; } @@ -2973,6 +2985,7 @@ export function PipelinePage(props: {
先从剧本认出角色 / 场景
// AI 认出角色 / 场景并出提示词,稍后你再逐个生成
+
// {pts(10)} 积分/次 · 失败不扣
{extractErr &&
{extractErr}
} )} @@ -2998,6 +3011,7 @@ export function PipelinePage(props: {
基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。

+ // 提取角色 / 场景 +{pts(10)} 积分/次 // 人物 +{pts(20)} 积分/张 // 场景 +{pts(20)} 积分/张 商品图无成本(直接复用商品库)