feat(viral-video): 爆款视频积分动态预估(estimate-credits) #2151

Merged
auto-approve-bot merged 2 commits from feat/viral-video-dynamic-credits into develop 2026-10-03 00:26:01 +08:00
2 changed files with 92 additions and 9 deletions
+11
View File
@@ -51,6 +51,17 @@ export function analyzeViralStyle(id: string) {
return apiClient.post<ViralVideoJob>(`/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 辅助函数(后端新接口上线后可替换) ── */
/**
@@ -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<ReturnType<typeof setTimeout> | null>(null)
const creditsAbortRef = useRef<number>(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 = () => {
</div>
</div>
<div className="vv-credits">
<span>
<SoundOutlined style={{ marginRight: 4 }} />
预计消耗
</span>
<strong>50 积分</strong>
</div>
{step3Enabled && (
<div className="vv-credits">
<span>
<SoundOutlined style={{ marginRight: 4 }} />
预计消耗
</span>
<strong>
{task.estimatedCreditsError
? "-- 积分"
: task.estimatedCredits === null
? "… 积分"
: `${task.estimatedCredits.toFixed(2)} 积分`}
</strong>
</div>
)}
{/* 视频预览区 */}
<div className="vv-section">