diff --git a/apps/web/src/api/viral-video/index.ts b/apps/web/src/api/viral-video/index.ts index 35c49321a..ac6526517 100644 --- a/apps/web/src/api/viral-video/index.ts +++ b/apps/web/src/api/viral-video/index.ts @@ -51,6 +51,17 @@ export function analyzeViralStyle(id: string) { return apiClient.post(`/viral-video/${id}/analyze-style`).then((r) => r.data) } +/** 动态预估积分消耗(STEP3 参数变化时调用) */ +export function estimateViralVideoCredits(params: { + video_model: string + resolution: string + video_ratio: string + duration: number +}) { + return apiClient + .post<{ estimated_credits: number }>("/viral-video/estimate-credits", params) + .then((r) => r.data) +} /** ── 三步拆分:前端 mock 辅助函数(后端新接口上线后可替换) ── */ /** diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.tsx b/apps/web/src/pages/viral-video/ViralVideoPage.tsx index 770fa642c..8d384b318 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.tsx +++ b/apps/web/src/pages/viral-video/ViralVideoPage.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from "react" +import axios from "axios" import { PlusOutlined, CloseOutlined, @@ -46,6 +47,7 @@ import { analyzeViralImages, generateViralCopy, confirmViralCopy, + estimateViralVideoCredits, } from "@/api/viral-video" import { useViralVideoPolling } from "./hooks/useViralVideoPolling" import CloneModal from "@/components/voice/CloneModal" @@ -123,6 +125,9 @@ type TabTask = { videoModel: string quality: string upscale: string + // 积分预估 + estimatedCredits: number | null // null=加载中/未发起;浮点数,展示时 toFixed(2) + estimatedCreditsError: boolean // true=接口失败显示 -- // UI 状态 uiStep: UIStep imageAnalysis: ImageAnalysisResult | null @@ -395,6 +400,8 @@ const emptyTask = (id: string, title: string): TabTask => ({ videoModel: "seedance-2.5", quality: "480p", upscale: "default", + estimatedCredits: null, + estimatedCreditsError: false, uiStep: "step1_upload", imageAnalysis: null, storyboard: null, @@ -565,6 +572,50 @@ const ViralVideoPage: React.FC = () => { ) useViralVideoPolling(task.jobId, onPollUpdate) + /* ── 积分动态预估:STEP2文案生成完成后首次调用;STEP3参数变化时防抖300ms刷新 ── */ + const creditsTimerRef = useRef | null>(null) + const creditsAbortRef = useRef(0) + useEffect(() => { + const creditsReady = + task.uiStep === "step2_copy_ready" || + task.uiStep === "step3_ready" || + task.uiStep === "step3_generating" || + task.uiStep === "step3_done" || + task.uiStep === "failed" + if (!creditsReady) { + // 未到可预估阶段:清空状态 + if (task.estimatedCredits !== null || task.estimatedCreditsError) { + setTask({ estimatedCredits: null, estimatedCreditsError: false }) + } + return + } + // 防抖 300ms + if (creditsTimerRef.current) clearTimeout(creditsTimerRef.current) + const seq = ++creditsAbortRef.current + creditsTimerRef.current = setTimeout(async () => { + try { + const res = await estimateViralVideoCredits({ + video_model: task.videoModel, + resolution: task.quality, + video_ratio: task.videoRatio, + duration: task.duration, + }) + if (seq !== creditsAbortRef.current) return + setTask({ + estimatedCredits: res.estimated_credits, + estimatedCreditsError: false, + }) + } catch { + if (seq !== creditsAbortRef.current) return + setTask({ estimatedCredits: null, estimatedCreditsError: true }) + } + }, 300) + return () => { + if (creditsTimerRef.current) clearTimeout(creditsTimerRef.current) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [task.videoModel, task.quality, task.videoRatio, task.duration, task.uiStep]) + /* ── OSS 上传 ── */ const uploadFile = useCallback( async (file: File, kind: "image" | "video" | "voice", onProgress?: (pct: number) => void) => { @@ -947,8 +998,21 @@ const ViralVideoPage: React.FC = () => { setTask({ job, jobId: job.id }) message.success("视频已提交生成,预计 1-3 分钟…") } catch (err: unknown) { - message.error(err instanceof Error ? err.message : "提交失败") - setTask({ uiStep: "failed", videoError: err instanceof Error ? err.message : "提交失败" }) + // 402 Payment Required → 积分不足 + if (axios.isAxiosError(err) && err.response?.status === 402) { + const detail = + (err.response.data as { detail?: string; message?: string; msg?: string } | undefined) + ?.detail || + (err.response.data as { detail?: string; message?: string; msg?: string } | undefined) + ?.message || + "积分不足,请充值" + message.error(detail) + setTask({ uiStep: "step3_ready", videoError: detail }) + return + } + const msg = err instanceof Error ? err.message : "提交失败" + message.error(msg) + setTask({ uiStep: "failed", videoError: msg }) } } @@ -2031,13 +2095,21 @@ const ViralVideoPage: React.FC = () => { -
- - - 预计消耗 - - 50 积分 -
+ {step3Enabled && ( +
+ + + 预计消耗 + + + {task.estimatedCreditsError + ? "-- 积分" + : task.estimatedCredits === null + ? "… 积分" + : `${task.estimatedCredits.toFixed(2)} 积分`} + +
+ )} {/* 视频预览区 */}