@@ -1,4 +1,4 @@
import React , { useCallback , useEffect , useMemo , useRef , useState } from "react"
import React , { useCallback , useEffect , useRef , useState } from "react"
import {
PlusOutlined ,
CloseOutlined ,
@@ -13,13 +13,15 @@ import {
WarningFilled ,
BulbFilled ,
AudioOutlined ,
ThunderboltOutlined ,
} from "@ant-design/icons"
import { Button as AntdButton , message , Select } from "antd"
import { uploadAssetDirect , getAssetsByKind } from "@/api/assets"
import { Select , message } from "antd"
import { uploadAssetDirect , getAssetLibraries , getAssetsByKind } from "@/api/assets"
import {
FUSION_LEVELS ,
STYLE_STRENGTHS ,
STAGE_DISPLAYS ,
STAGE_ORDER ,
type FusionLevel ,
type StyleStrength ,
type ViralVideoJob ,
@@ -43,11 +45,18 @@ type TabTask = {
uploading : boolean
pct : number
ossUrl? : string
file? : File
} [ ]
refVideo : { url : string ; name : string ; uploading : boolean ; pct : number ; ossUrl? : string } | null
refVideo : {
url : string
name : string
uploading : boolean
pct : number
ossUrl? : string
file? : File
} | null
fusionLevel : FusionLevel
styleStrength : StyleStrength
styleTemplateId : string
voiceId : string
userCopy : string
industry : string
@@ -75,7 +84,10 @@ type TabTask = {
job : ViralVideoJob | null
jobId : string | null
playingVoiceId : string | null
audioRef : HTMLAudioElement | null
audioInst : HTMLAudioElement | null
// 按钮 loading
analyzing : boolean
generating : boolean
}
const INDUSTRIES = [
@@ -114,7 +126,6 @@ const emptyTask = (id: string, title: string): TabTask => ({
refVideo : null ,
fusionLevel : "full_ai" ,
styleStrength : "medium" ,
styleTemplateId : "" ,
voiceId : "" ,
userCopy : "" ,
industry : "" ,
@@ -141,10 +152,12 @@ const emptyTask = (id: string, title: string): TabTask => ({
job : null ,
jobId : null ,
playingVoiceId : null ,
audioRef : null ,
audioInst : null ,
analyzing : false ,
generating : false ,
} )
/* ─────────── 折叠 Section 组件 ─────────── */
/* ─────────── 折叠 Section ─────────── */
const Section : React.FC < {
title : React.ReactNode
step? : number
@@ -173,8 +186,6 @@ const ViralVideoPage: React.FC = () => {
const [ voiceAssets , setVoiceAssets ] = useState <
{ id : string ; name : string ; url : string ; desc? : string } [ ]
> ( [ ] )
const [ submitting , setSubmitting ] = useState ( false )
const [ confirmingIntent , setConfirmingIntent ] = useState ( false )
const dragImgRef = useRef < HTMLInputElement > ( null )
const dragVideoRef = useRef < HTMLInputElement > ( null )
const dragFromIdxRef = useRef < number > ( - 1 )
@@ -218,30 +229,45 @@ const ViralVideoPage: React.FC = () => {
const onPollUpdate = useCallback (
( job : ViralVideoJob ) = > {
setTask ( ( t ) = > {
// 首次拿到 wait_user_confirm 时, 初始化 intent 编辑文本
const prevIntent = t . job ? . status
// 首次进入 wait_user_confirm 时初始化 intent 编辑文本
const prevStatus = t . job ? . status
const newIntentEdit =
prevIntent !== "wait_user_confirm" && job . status === "wait_user_confirm"
prevStatus !== "wait_user_confirm" && job . status === "wait_user_confirm"
? job . intent_result ? . suggested_copy || t.userCopy
: t.intentEditText
return { . . . t , job , intentEditText : newIntentEdit }
return {
. . . t ,
job ,
intentEditText : newIntentEdit ,
analyzing :
job.status === "wait_user_confirm" ||
job . status === "completed" ||
job . status === "failed"
? false
: t . analyzing ,
generating :
job.status === "completed" ||
job . status === "failed" ||
job . status === "wait_user_confirm"
? false
: t . generating ,
}
} )
} ,
[ setTask ] ,
)
useViralVideoPolling ( task . jobId , onPollUpdate )
/* ── 工具函数: 上传文件到 OSS ── */
/* ── 上传文件到 OSS ── */
const uploadFile = useCallback (
async (
file : File ,
kind : "image" | "video" | "voice" ,
onProgress ? : ( pct : number ) = > Promise < void > | void ,
) = > {
async ( file : File , kind : "image" | "video" | "voice" , onProgress ? : ( pct : number ) = > void ) = > {
const libs = await getAssetLibraries ( )
const lib = ( libs as { kind : string ; id : string } [ ] ) . find ( ( l ) = > l . kind === kind ) || libs [ 0 ]
if ( ! lib ) throw new Error ( "未找到素材库" )
const res = await uploadAssetDirect ( {
file ,
kind ,
onProgress : ( p : number ) = > onProgress ? . ( Math . round ( p * 100 ) ) ,
library_id : lib.id ,
onProgress : ( p : number ) = > onProgress ? . ( Math . round ( p ) ) ,
} )
return res . url
} ,
@@ -251,7 +277,12 @@ const ViralVideoPage: React.FC = () => {
/* ── 上传图片 ── */
const handleImageFiles = useCallback (
async ( files : FileList | File [ ] ) = > {
const arr = Array . from ( files ) . slice ( 0 , 9 - task . images . length )
const remaining = 9 - task . images . length
if ( remaining <= 0 ) {
message . warning ( "最多上传 9 张产品图" )
return
}
const arr = Array . from ( files ) . slice ( 0 , remaining )
if ( arr . length === 0 ) return
const newImgs = arr . map ( ( f ) = > ( {
localId : ` ${ Date . now ( ) } _ ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 7 ) } ` ,
@@ -283,7 +314,7 @@ const ViralVideoPage: React.FC = () => {
im . localId === item . localId ? { . . . im , uploading : false , pct : 100 , ossUrl : url } : im ,
) ,
} ) )
} catch ( err ) {
} catch {
message . error ( ` ${ item . name } 上传失败 ` )
setTask ( ( t ) = > ( { . . . t , images : t.images.filter ( ( im ) = > im . localId !== item . localId ) } ) )
}
@@ -332,9 +363,8 @@ const ViralVideoPage: React.FC = () => {
/* ── 上传参考视频 ── */
const handleRefVideo = useCallback (
async ( file : File ) = > {
setTask ( {
refVideo : { url : URL.createObjectURL ( file ) , name : file.name , uploading : true , pct : 0 } ,
} )
const localUrl = URL . createObjectURL ( file )
setTask ( { refVideo : { url : localUrl , name : file.name , uploading : true , pct : 0 } } )
try {
const url = await uploadFile ( file , "video" , ( p ) = > {
setTask ( ( t ) = > ( t . refVideo ? { . . . t , refVideo : { . . . t . refVideo , pct : p } } : t ) )
@@ -346,13 +376,14 @@ const ViralVideoPage: React.FC = () => {
)
} catch {
message . error ( "参考视频上传失败" )
if ( localUrl . startsWith ( "blob:" ) ) URL . revokeObjectURL ( localUrl )
setTask ( { refVideo : null } )
}
} ,
[ setTask , uploadFile ] ,
)
/* ── 新建/关闭 Tab ── */
/* ── Tab 管理 ── */
const newTab = ( ) = > {
const id = ` t ${ Date . now ( ) } `
setTasks ( ( ts ) = > [ . . . ts , emptyTask ( id , ` 生成 ${ ts . length + 1 } ` ) ] )
@@ -362,29 +393,25 @@ const ViralVideoPage: React.FC = () => {
setTasks ( ( ts ) = > {
const next = ts . filter ( ( t ) = > t . id !== id )
if ( next . length === 0 ) {
const fresh = emptyTask ( "t1" , "生成 1" )
setActiveId ( "t1" )
return [ fresh ]
return [ emptyTask ( "t1" , "生成 1" ) ]
}
if ( id === activeId ) setActiveId ( next [ 0 ] . id )
return next
} )
}
/* ── 重置当前任务 ── */
const resetTask = ( ) = > {
// 停掉播放
audioRef . current ? . pause ( )
setTasks ( ( ts ) = > ts . map ( ( t ) = > ( t . id === activeId ? emptyTask ( t . id , t . title ) : t ) ) )
}
/* ── 开始生成 ── */
const handleGenerate = async ( ) = > {
/* ── STEP1:开始分析(触发图片分析+视频分析+意图理解 → WAIT_USER_CONFIRM 暂停) ── */
const handleAnalyze = async ( ) = > {
const imgUrls = task . images
. filter ( ( i ) = > i . ossUrl && ! i . uploading )
. map ( ( i ) = > i . ossUrl as string )
if ( imgUrls . length === 0 ) {
message . warning ( "请至少上传 1 张产品图,等图片 上传完成后再生成 " )
message . warning ( "请至少上传 1 张产品图,等待 上传完成后再分析 " )
return
}
if ( task . images . some ( ( i ) = > i . uploading ) ) {
@@ -395,11 +422,7 @@ const ViralVideoPage: React.FC = () => {
message . warning ( "参考视频上传中,请稍候" )
return
}
if ( ! task . voiceId ) {
message . warning ( "请先选择一个配音音色" )
return
}
setSubmitting ( true )
setTask ( { analyzing : true } )
try {
const job = await generateViralVideo ( {
images : imgUrls ,
@@ -407,7 +430,10 @@ const ViralVideoPage: React.FC = () => {
style_strength : task.refVideo?.ossUrl ? task.styleStrength : undefined ,
user_copy_text : task.fusionLevel !== "full_ai" ? task.userCopy : undefined ,
fusion_level : task.fusionLevel ,
voice_id : task.voiceId ,
// 注意:STEP1「开始分析」阶段还没有真正开始生成视频,后端会跑到 WAIT_USER_CONFIRM 暂停。
// 配音和视频参数在 STEP2 确认文案时由 confirm-intent 继续使用;但后端接口目前只接收一次 payload,
// 因此先把配音/参数一并提交(用户可在确认前继续调整配音、比例等,但最终生成用的是提交时的值)。
voice_id : task.voiceId || undefined ,
bgm_preference : task.bgm === "无BGM" ? undefined : task . bgm ,
industry : task.industry || undefined ,
target_customer : task.targetCustomer || undefined ,
@@ -418,27 +444,25 @@ const ViralVideoPage: React.FC = () => {
video_model : task.videoModel ,
video_ratio : task.videoRatio ,
} )
setTask ( { job , jobId : job.id } )
message . success ( "任务已创建,开始生成 …" )
setTask ( { job , jobId : job.id , analyzing : false } )
message . success ( "开始分析,稍候会给出 AI 理解的文案 …" )
} catch ( err : unknown ) {
const msg = err instanceof Error ? err . message : "提交失败"
message . error ( msg )
} finally {
setSubmitting ( false )
message . error ( err instanceof Error ? err . message : "提交失败" )
setTask ( { analyzing : false } )
}
}
const handleConfirmIntent = async ( ) = > {
/* ── STEP2:确认并生成视频(文案融合→分镜→审核→TTS→渲染→上传,一跑到底) ── */
const handleConfirmAndGenerate = async ( ) = > {
if ( ! task . jobId ) return
setConfirmingIntent ( true )
setTask ( { generating : true } )
try {
const j = await confirmViralVideoIntent ( task . jobId , { confirmed_copy : task.intentEditText } )
setTask ( { job : j } )
message . success ( "已确认文案,继续生成 …" )
message . success ( "文案 已确认,开始生成视频 …" )
} catch ( err : unknown ) {
message . error ( err instanceof Error ? err . message : "确认失败" )
} finally {
setConfirmingIntent ( false )
setTask ( { generating : false } )
}
}
@@ -453,7 +477,7 @@ const ViralVideoPage: React.FC = () => {
}
}
/* ── 播放/暂停 音色 ── */
/* ── 音色播放 ── */
const toggleVoice = ( v : { id : string ; url : string } ) = > {
if ( task . playingVoiceId === v . id ) {
audioRef . current ? . pause ( )
@@ -465,20 +489,14 @@ const ViralVideoPage: React.FC = () => {
a . onended = ( ) = > setTask ( { playingVoiceId : null } )
a . play ( ) . catch ( ( ) = > { } )
audioRef . current = a
setTask ( { playingVoiceId : v.id , audioRef : a } )
setTask ( { playingVoiceId : v.id , audioInst : a } )
}
useEffect (
( ) = > ( ) = > {
audioRef . current ? . pause ( )
} ,
[ ] ,
)
useEffect ( ( ) = > ( ) = > audioRef . current ? . pause ( ) , [ ] )
const onCloneSuccess = ( voice : { id? : string ; name? : string ; url? : string } ) = > {
const onCloneSuccess = ( voice : { id? : string } ) = > {
setCloneModalOpen ( false )
if ( voice ? . id ) {
setTask ( { voiceId : voice.id } )
// 刷新音色列表
getAssetsByKind ( "voice" , { limit : 50 } )
. then ( ( list ) = >
setVoiceAssets (
@@ -490,18 +508,39 @@ const ViralVideoPage: React.FC = () => {
}
}
/* ── 运行状态派生值 ── */
const isRunning = task . job ? . status === "running" || task . job ? . status === "pending"
/* ── 派生状态 ── */
const isAwaitingConfirm = task . job ? . status === "wait_user_confirm"
const isPreAnalysis = ! task . jobId && ! task . analyzing // 还没点开始分析
const isAnalyzing =
task . analyzing ||
( task . job &&
task . job . status === "running" &&
! isAwaitingConfirm &&
! task . generating &&
( ! task . job . progress_stage ||
[ "image_analysis" , "video_analysis" , "intent_parsing" ] . includes ( task . job . progress_stage ) ) )
const isGenerating =
task . generating ||
( task . job ? . status === "running" &&
task . job . progress_stage &&
! [ "image_analysis" , "video_analysis" , "intent_parsing" ] . includes ( task . job . progress_stage ) )
const isDone = task . job ? . status === "completed"
const isFailed = task . job ? . status === "failed"
const progressPct = useMemo ( ( ) = > {
if ( ! task . job ) return 0
if ( task . job . progress_percent ) return task . job . progress_percent
if ( task . job . progress_stage ) return STAGE_DISPLAYS [ task . job . progress_stage ] ? . pct ? ? 0
return 0
} , [ task . job ] )
const currentStage : ViralVideoStage | undefined = task . job ? . progress_stage
const progressPct = task . job ? . progress_percent ? ? 0
const currentStage : ViralVideoStage | undefined = task . job ? . progress_stage as
ViralVideoStage | undefined
/* ── 阶段列表派生显示状态 ── */
const stageState = ( s : ViralVideoStage ) : "pending" | "active" | "done" | "failed" = > {
if ( isFailed && s === currentStage ) return "failed"
if ( isDone ) return "done"
if ( ! currentStage ) return "pending"
const cur = STAGE_DISPLAYS [ currentStage ] . pct
const mine = STAGE_DISPLAYS [ s ] . pct
if ( s === currentStage ) return "active"
if ( mine < cur ) return "done"
return "pending"
}
return (
< div className = "vv-page" >
@@ -535,12 +574,11 @@ const ViralVideoPage: React.FC = () => {
< / div >
< / div >
{ /* 三列 */ }
< div className = "vv-grid" >
{ /* ─────────── 左列: STEP 1 上传素材 ─────────── */ }
{ /* ─────────── 左列 STEP 1 ─────────── */ }
< div className = "vv-col" >
< Section
title = "上传图片"
title = "上传产品 图片"
step = { 1 }
collapsed = { task . collapsed . images }
onToggle = { ( ) = > toggleCollapse ( "images" ) }
@@ -674,6 +712,7 @@ const ViralVideoPage: React.FC = () => {
key = { s . value }
className = { ` vv-pill ${ task . styleStrength === s . value ? "active" : "" } ` }
onClick = { ( ) = > setTask ( { styleStrength : s.value } ) }
disabled = { ! isPreAnalysis }
>
{ s . label }
< / button >
@@ -686,7 +725,7 @@ const ViralVideoPage: React.FC = () => {
< / Section >
< Section
title = "配音 选择"
title = "选择配音 "
step = { 1 }
collapsed = { task . collapsed . voice }
onToggle = { ( ) = > toggleCollapse ( "voice" ) }
@@ -706,12 +745,12 @@ const ViralVideoPage: React.FC = () => {
< / button >
< / div >
< div className = "vv-voice-list" >
{ voiceTab === "preset" && voiceAssets . length === 0 && (
{ voiceAssets . length === 0 && (
< div className = "vv-muted" style = { { padding : 24 , textAlign : "center" } } >
暂 无 可 用 音 色 , 先 去 「 我 的 音 色 」 上 传
暂 无 可 用 音 色 , 去 「 我 的 音 色 」 上 传
< / div >
) }
{ ( voiceTab === "preset" ? voiceAssets : voiceAssets ) . map ( ( v ) = > (
{ voiceAssets . map ( ( v ) = > (
< div
key = { v . id }
className = { ` vv-voice-item ${ task . voiceId === v . id ? "selected" : "" } ` }
@@ -745,13 +784,41 @@ const ViralVideoPage: React.FC = () => {
>
< AudioOutlined / > 上 传 / 录 制 我 的 音 色
< / button >
{ /* STEP1 主按钮:开始分析 */ }
< button
className = "vv-btn vv-btn-primary"
disabled = { task . analyzing || ( ! isPreAnalysis && ! isFailed ) }
onClick = { handleAnalyze }
>
{ task . analyzing ? (
< >
< span className = "vv-spinner" / > 分 析 中 …
< / >
) : isAnalyzing ? (
< >
< span className = "vv-spinner" / > 分 析 中 …
< / >
) : isFailed ? (
< >
< ReloadOutlined / > 重 新 开 始
< / >
) : (
< >
< ThunderboltOutlined / > 开 始 分 析
< / >
) }
< / button >
< div className = "vv-meta" style = { { marginTop : 6 } } >
< span className = "vv-muted" > 点 击 后 将 开 始 图 片 / 视 频 风 格 分 析 与 意 图 理 解 < / span >
< / div >
< / Section >
< / div >
{ /* ─────────── 中列: STEP 2 生成视频文案 ─────────── */ }
{ /* ─────────── 中列 STEP 2 ─────────── */ }
< div className = "vv-col" >
< Section
title = "生成视频 文案"
title = "文案融合方式 "
step = { 2 }
collapsed = { task . collapsed . fusion }
onToggle = { ( ) = > toggleCollapse ( "fusion" ) }
@@ -762,6 +829,7 @@ const ViralVideoPage: React.FC = () => {
key = { f . value }
className = { ` vv-fusion-btn ${ task . fusionLevel === f . value ? "active" : "" } ` }
onClick = { ( ) = > setTask ( { fusionLevel : f.value } ) }
disabled = { ! isPreAnalysis }
>
< strong > { f . label } < / strong >
{ f . desc }
@@ -783,6 +851,7 @@ const ViralVideoPage: React.FC = () => {
}
value = { task . userCopy }
onChange = { ( e ) = > setTask ( { userCopy : e.target.value } ) }
disabled = { ! isPreAnalysis }
/ >
< / div >
< / Section >
@@ -804,6 +873,7 @@ const ViralVideoPage: React.FC = () => {
onChange = { ( v ) = > setTask ( { industry : v } ) }
options = { INDUSTRIES . map ( ( i ) = > ( { value : i , label : i } ) ) }
allowClear
disabled = { ! isPreAnalysis }
/ >
< / div >
< div className = "vv-form-row" >
@@ -813,6 +883,7 @@ const ViralVideoPage: React.FC = () => {
placeholder = "例:25-35岁职场女性"
value = { task . targetCustomer }
onChange = { ( e ) = > setTask ( { targetCustomer : e.target.value } ) }
disabled = { ! isPreAnalysis }
/ >
< / div >
< div className = "vv-form-row" >
@@ -823,6 +894,7 @@ const ViralVideoPage: React.FC = () => {
value = { task . language }
onChange = { ( v ) = > setTask ( { language : v } ) }
options = { LANGUAGES . map ( ( i ) = > ( { value : i , label : i } ) ) }
disabled = { ! isPreAnalysis }
/ >
< / div >
< div className = "vv-form-row" >
@@ -833,6 +905,7 @@ const ViralVideoPage: React.FC = () => {
value = { task . duration }
onChange = { ( v ) = > setTask ( { duration : v } ) }
options = { [ 15 , 20 , 30 , 45 , 60 ] . map ( ( n ) = > ( { value : n , label : ` ${ n } 秒 ` } ) ) }
disabled = { ! isPreAnalysis }
/ >
< / div >
< div className = "vv-form-row" >
@@ -843,6 +916,7 @@ const ViralVideoPage: React.FC = () => {
value = { task . viralStructure }
onChange = { ( v ) = > setTask ( { viralStructure : v } ) }
options = { STRUCTURES . map ( ( i ) = > ( { value : i , label : i } ) ) }
disabled = { ! isPreAnalysis }
/ >
< / div >
< div className = "vv-form-row" >
@@ -853,6 +927,7 @@ const ViralVideoPage: React.FC = () => {
value = { task . marketingPurpose }
onChange = { ( v ) = > setTask ( { marketingPurpose : v } ) }
options = { PURPOSES . map ( ( i ) = > ( { value : i , label : i } ) ) }
disabled = { ! isPreAnalysis }
/ >
< / div >
< div className = "vv-form-row" style = { { gridColumn : "1 / -1" } } >
@@ -863,6 +938,7 @@ const ViralVideoPage: React.FC = () => {
key = { b }
className = { ` vv-pill ${ task . bgm === b ? "active" : "" } ` }
onClick = { ( ) = > setTask ( { bgm : b } ) }
disabled = { ! isPreAnalysis }
>
{ b }
< / button >
@@ -872,12 +948,34 @@ const ViralVideoPage: React.FC = () => {
< / div >
< / Section >
{ isAwaitingConfirm && task . job ? . intent_result && (
{ /* AI 意图确认卡:wait_user_confirm 或生成中都显示 */ }
{ ( isAwaitingConfirm || isGenerating || isDone ) && task . job ? . intent_result && (
< div className = "vv-section" >
< div className = "vv-section-body" >
< div className = "vv-intent" >
< div className = "vv-intent-title" >
< BulbFilled / > AI 文 案 已 生 成 , 请 确 认 后 继 续
< BulbFilled / > AI 文 案 理 解
{ isAwaitingConfirm && (
< span
style = { { marginLeft : 6 , fontWeight : 400 , fontSize : 12 , color : "#b45309" } }
>
( 请 确 认 后 开 始 生 成 视 频 )
< / span >
) }
{ isGenerating && (
< span
style = { { marginLeft : 6 , fontWeight : 400 , fontSize : 12 , color : "#10b981" } }
>
( 已 确 认 , 正 在 生 成 )
< / span >
) }
{ isDone && (
< span
style = { { marginLeft : 6 , fontWeight : 400 , fontSize : 12 , color : "#10b981" } }
>
( 已 完 成 )
< / span >
) }
< / div >
{ task . job . intent_result . product && (
< div className = "vv-intent-row" >
@@ -909,32 +1007,42 @@ const ViralVideoPage: React.FC = () => {
< div className = "vv-v" > { task . job . intent_result . tone } < / div >
< / div >
) }
< div className = "vv-form-row" style = { { marginTop : 10 } } >
< label className = "vv-label" > 可 编 辑 文 案 < / label >
< textarea
className = "vv-textarea"
value = { task . intentEditText }
onChange = { ( e ) = > setTask ( { intentEditText : e.target.value } ) }
/ >
< / div >
< div className = "vv-flex vv-gap-8 vv-mt-8" >
< button
className = "vv-btn vv-btn-primary"
style = { { flex : 1 , marginTop : 0 } }
disabled = { confirmingIntent }
onClick = { handleConfirmIntent }
>
{ confirmingIntent ? (
< >
< span className = "vv-spinner" / > 提 交 中 …
< / >
) : (
< >
< CheckOutlined / > 确 认 并 继 续 生 成
< / >
) }
< / button >
< / div >
{ isAwaitingConfirm && (
< >
< div className = "vv-form-row" style = { { marginTop : 10 } } >
< label className = "vv-label" > 可 编 辑 文 案 < / label >
< textarea
className = "vv-textarea"
value = { task . intentEditText }
onChange = { ( e ) = > setTask ( { intentEditText : e.target.value } ) }
/ >
< / div >
< button
className = "vv-btn vv-btn-primary"
style = { { marginTop : 0 } }
disabled = { task . generating }
onClick = { handleConfirmAndGenerate }
>
{ task . generating ? (
< >
< span className = "vv-spinner" / > 生 成 中 …
< / >
) : (
< >
< VideoCameraOutlined / > 确 认 文 案 并 生 成 视 频
< / >
) }
< / button >
< / >
) }
{ ! isAwaitingConfirm && task . job . final_copy_text && (
< div className = "vv-intent-row" style = { { marginTop : 8 } } >
< div className = "vv-k" > 最 终 文 案 < / div >
< div className = "vv-v" style = { { whiteSpace : "pre-wrap" , fontSize : 12 } } >
{ task . job . final_copy_text }
< / div >
< / div >
) }
< / div >
< / div >
< / div >
@@ -955,7 +1063,7 @@ const ViralVideoPage: React.FC = () => {
) }
< / div >
{ /* ─────────── 右列: STEP 3 生成视频 ─────────── */ }
{ /* ─────────── 右列 STEP 3 ─────────── */ }
< div className = "vv-col" >
< Section
title = "视频预览"
@@ -970,24 +1078,23 @@ const ViralVideoPage: React.FC = () => {
< div className = "vv-preview-placeholder" >
< div className = "ph-icon" > 🎬 < / div >
< div className = "ph-txt" >
{ isRunning || isAwaitingConfirm
? "生成中,完成后将在此预览"
: "提交后视频将在此处预览" }
{ isGenerating
? "视频 生成中,完成后将在此预览"
: isAwaitingConfirm
? "请在 STEP 2 确认文案后开始生成"
: "完成前面步骤后视频将在此处预览" }
< / div >
< / div >
) }
< / div >
{ isDone && task . job ? . output_url && (
< AntdButton
type = "primary "
block
icon = { < ReloadOutlined / > }
className = "vv-mt-8"
style = { { background : "linear-gradient(135deg,#7c3aed,#a855f7)" , border : "none" } }
< button
className = "vv-btn vv-btn-ghost vv-mt-8 "
style = { { width : "100%" } }
onClick = { handleRetry }
>
重 新 生 成
< / AntdButton >
< ReloadOutlined / > 重 新 生 成
< / button >
) }
< / Section >
@@ -1005,7 +1112,7 @@ const ViralVideoPage: React.FC = () => {
value = { task . videoRatio }
onChange = { ( v ) = > setTask ( { videoRatio : v } ) }
options = { RATIOS . map ( ( r ) = > ( { value : r.v , label : r.label } ) ) }
disabled = { ! ! task . jobId }
disabled = { ! isPreAnalysis }
/ >
< / div >
< div className = "vv-form-row" style = { { marginBottom : 0 } } >
@@ -1016,7 +1123,7 @@ const ViralVideoPage: React.FC = () => {
value = { task . videoModel }
onChange = { ( v ) = > setTask ( { videoModel : v } ) }
options = { MODELS . map ( ( m ) = > ( { value : m.v , label : m.label } ) ) }
disabled = { ! ! task . jobId }
disabled = { ! isPreAnalysis }
/ >
< / div >
< / Section >
@@ -1027,7 +1134,7 @@ const ViralVideoPage: React.FC = () => {
collapsed = { task . collapsed . stages }
onToggle = { ( ) = > toggleCollapse ( "stages" ) }
>
{ ( isRunning || isAwaitingConfirm ) && (
{ task . jobId && ( isAnalyzing || isGenerating || isDone || isFailed ) && (
< >
< div className = "vv-progress-bar" >
< div className = "vv-progress-fill" style = { { width : ` ${ progressPct } % ` } } / >
@@ -1039,22 +1146,14 @@ const ViralVideoPage: React.FC = () => {
< / >
) }
< div className = "vv-stages" >
{ ( Object . keys ( STAGE_DISPLAYS ) as ViralVideoStage [ ] ) . map ( ( s ) = > {
const isDoneStage = currentStage
? STAGE_DISPLAYS [ s ] . pct < STAGE_DISPLAYS [ currentStage ] . pct
: false
const isActiveStage = s === currentStage
let cls = ""
if ( isFailed ) cls = s === currentStage ? "failed" : isDoneStage ? "done" : ""
else if ( isDone ) cls = "done"
else if ( isActiveStage ) cls = "active"
else if ( isDoneStage ) cls = "done"
{ STAGE_ORDER . map ( ( s ) = > {
const st = stageState ( s )
return (
< div key = { s } className = { ` vv-stage ${ cls } ` } >
< div key = { s } className = { ` vv-stage ${ st } ` } >
< span className = "vv-stage-dot" >
{ cls === "done" ? (
{ st === "done" ? (
< CheckOutlined style = { { fontSize : 8 } } / >
) : cls === "failed" ? (
) : st === "failed" ? (
"× "
) : (
""
@@ -1075,27 +1174,41 @@ const ViralVideoPage: React.FC = () => {
< strong > { task . duration >= 45 ? 80 : task.duration >= 30 ? 60 : 40 } 积 分 < / strong >
< / div >
< button
className = "vv-btn vv-btn-primary"
disabled = { submitting || isRunning || isAwaitingConfirm }
onClick = { handleGenerate }
>
{ submitting ? (
< >
< span className = "vv-spinner" / > 提 交 中 …
< / >
) : isRunning ? (
< >
< span className = "vv-spinner" / > 生 成 中 { progressPct } % …
< / >
) : isAwaitingConfirm ? (
"等待文案确认…"
) : (
< >
< VideoCameraOutlined / > 开 始 生 成 视 频
< / >
) }
< / button >
{ isPreAnalysis && (
< div
className = "vv-muted"
style = { {
padding : "12px 16px" ,
background : "#f9fafb" ,
borderRadius : 8 ,
fontSize : 12 ,
textAlign : "center" ,
} }
>
请 先 在 STEP 1 上 传 素 材 并 点 击 「 开 始 分 析 」
< / div >
) }
{ isAwaitingConfirm && (
< div
className = "vv-muted"
style = { {
padding : "12px 16px" ,
background : "#fffbeb" ,
border : "1px solid #fcd34d" ,
borderRadius : 8 ,
fontSize : 12 ,
textAlign : "center" ,
color : "#b45309" ,
} }
>
请 在 STEP 2 确 认 AI 文 案 后 点 击 「 确 认 文 案 并 生 成 视 频 」
< / div >
) }
{ isGenerating && (
< button className = "vv-btn vv-btn-primary" disabled >
< span className = "vv-spinner" / > 视 频 生 成 中 { progressPct } % …
< / button >
) }
< / div >
< / div >