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