Compare commits

...

2 Commits

Author SHA1 Message Date
saas-backend c0c29bb0c2 feat(digital-human): 对口型生成弹窗增加计时器
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m44s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m51s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m56s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m31s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m36s
AI Code Review / AI Code Review (pull_request) Successful in 6m43s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 8m32s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 9m41s
- 对口型生成弹窗loading状态显示已用时(MM:SS格式,每秒更新)
- 使用useState+useEffect+setInterval实现计时,组件卸载时clearInterval防内存泄漏
- 取消生成或生成完成/失败时停止并重置计时器
- 样式与现有灰色提示文字风格一致
2026-09-24 13:44:03 +08:00
xiaoxia c9a8691b77 refactor(generate): Step4删除生成配置卡片;Step5默认封面模板+完成按钮保存入库 (#2024)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m17s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m21s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m26s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m36s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m3s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m55s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m42s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Validate - Style (push) Successful in 5m44s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m45s
CI/CD Pipeline / Integration Tests (push) Successful in 6m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m44s
AI Code Review / AI Code Review (pull_request) Successful in 6m54s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 11m32s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 12m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m0s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 16m25s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
refactor(generate): Step4删除生成配置卡片;Step5默认封面模板+完成按钮保存入库 (#2024)
2026-09-24 11:11:30 +08:00
6 changed files with 148 additions and 64 deletions
+41 -1
View File
@@ -61,6 +61,9 @@ const AiAvatarPage: React.FC = () => {
"generating",
)
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
const [lipsyncElapsed, setLipsyncElapsed] = useState(0)
/* ── 对口型计时器 ── */
const lipsyncElapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
/* ── 渲染进度弹窗 ── */
const [showRenderModal, setShowRenderModal] = useState(false)
const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">(
@@ -80,6 +83,30 @@ const AiAvatarPage: React.FC = () => {
/* ── 渲染进度轮询 ── */
const renderTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
/* ── 对口型计时器工具 ── */
const _formatElapsed = useCallback((seconds: number) => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
}, [])
const _startLipsyncElapsedTimer = useCallback(() => {
if (lipsyncElapsedTimerRef.current) {
clearInterval(lipsyncElapsedTimerRef.current)
}
setLipsyncElapsed(0)
lipsyncElapsedTimerRef.current = setInterval(() => {
setLipsyncElapsed((prev) => prev + 1)
}, 1000)
}, [])
const _stopLipsyncElapsedTimer = useCallback(() => {
if (lipsyncElapsedTimerRef.current) {
clearInterval(lipsyncElapsedTimerRef.current)
lipsyncElapsedTimerRef.current = null
}
}, [])
const togglePanel = useCallback((key: PanelKey) => {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
}, [])
@@ -222,6 +249,7 @@ const AiAvatarPage: React.FC = () => {
setShowLipsyncModal(true)
setLipsyncStatus("generating")
setLipsyncErrorMessage("")
_startLipsyncElapsedTimer()
const asset = await getAssetById(video.id)
const videoUrl = asset?.file_url
@@ -265,6 +293,7 @@ const AiAvatarPage: React.FC = () => {
state.setLipsyncJob(updated)
if (updated.status === "completed") {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
_stopLipsyncElapsedTimer()
setLipsyncStatus("completed")
setTimeout(() => {
setShowLipsyncModal(false)
@@ -272,6 +301,7 @@ const AiAvatarPage: React.FC = () => {
}, 1000)
} else if (updated.status === "failed") {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
_stopLipsyncElapsedTimer()
setLipsyncStatus("failed")
setLipsyncErrorMessage(updated.error_message || "对口型生成失败")
}
@@ -285,6 +315,8 @@ const AiAvatarPage: React.FC = () => {
data: (err as { response?: { data?: unknown } })?.response?.data,
message: err instanceof Error ? err.message : String(err),
})
_stopLipsyncElapsedTimer()
setLipsyncElapsed(0)
setShowLipsyncModal(false)
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
}
@@ -297,6 +329,8 @@ const AiAvatarPage: React.FC = () => {
state.style,
state.ttsPreview,
_startLipsyncElapsedTimer,
_stopLipsyncElapsedTimer,
])
// 取消对口型生成
@@ -305,16 +339,19 @@ const AiAvatarPage: React.FC = () => {
clearInterval(lipsyncTimerRef.current)
lipsyncTimerRef.current = null
}
_stopLipsyncElapsedTimer()
setLipsyncElapsed(0)
setShowLipsyncModal(false)
setLipsyncStatus("generating")
setLipsyncErrorMessage("")
}, [])
}, [_stopLipsyncElapsedTimer])
// 清理轮询
useEffect(() => {
return () => {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
if (lipsyncElapsedTimerRef.current) clearInterval(lipsyncElapsedTimerRef.current)
}
}, [])
@@ -964,6 +1001,9 @@ const AiAvatarPage: React.FC = () => {
对口型视频生成中…
</div>
<div style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1" }}>
已用时:{_formatElapsed(lipsyncElapsed)}
</div>
<div style={{ marginTop: 4, fontSize: 13, color: "#8c8ca1" }}>
请勿关闭页面,完成后将自动提示
</div>
</>
+79 -10
View File
@@ -21,6 +21,7 @@ import GenerateStepActions from "./components/GenerateStepActions"
import { useGenerateFormState } from "./hooks/useGenerateFormState"
import { useStepNavigation } from "./hooks/useStepNavigation"
import { useGenerateVideo } from "./hooks/useGenerateVideo"
import { confirmGeneration } from "@/api/generation/confirm"
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
@@ -139,6 +140,9 @@ const GeneratePage: React.FC = () => {
/* ── 数量选择弹窗 ── */
const [countModalOpen, setCountModalOpen] = useState(false)
/* ── Step5 保存中状态 ── */
const [finishing, setFinishing] = useState(false)
/* ── #1970 流程重构:分支弹窗 ── */
const [voiceModalOpen, setVoiceModalOpen] = useState(false)
const [scriptModalOpen, setScriptModalOpen] = useState(false)
@@ -404,9 +408,81 @@ const GeneratePage: React.FC = () => {
},
})
/* ── 最终成片 ── */
/* ── 最终成片(单视频) ── */
const finalVideo = generatedVideos[0]
/* ── Step5 完成:调用 confirm 入库 + 跳转 ── */
const handleFinish = useCallback(async () => {
if (finishing) return
// 校验:单视频必须已生成;批量必须所有已选视频有封面或确认跳过
if (isBatch) {
if (generatedVideos.length === 0) {
message.warning("请等待视频生成完成")
return
}
} else {
if (!finalVideo) {
message.warning("请等待视频生成完成")
return
}
}
setFinishing(true)
const hide = message.loading("正在保存到视频库...", 0)
try {
const taskIds =
batchTasks && batchTasks.length > 0
? batchTasks.map((t) => t.taskId).filter(Boolean)
: finalVideo?.generation_task_id
? [finalVideo.generation_task_id]
: []
// 单视频/批量:为每个任务调用 confirm(传入封面)
if (isBatch && previewCovers.length > 0) {
await Promise.all(
taskIds.map(async (taskId, idx) => {
const coverUrl = previewCovers[idx] || ""
return confirmGeneration(taskId, {
cover_url: coverUrl || undefined,
custom_title: previewTitles[idx] || titleSettings.title || "",
})
}),
)
} else if (finalVideo?.generation_task_id) {
const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
await confirmGeneration(finalVideo.generation_task_id, {
cover_url: coverUrl || undefined,
custom_title: titleSettings.title || "",
})
}
hide()
message.success("已保存到视频库")
navigate("/app/products")
} catch (err) {
hide()
console.error("[保存失败]", err)
const detail =
(err as { response?: { data?: { detail?: string; message?: string } } })?.response?.data
?.detail ||
(err as { response?: { data?: { detail?: string; message?: string } } })?.response?.data
?.message ||
"保存失败,请稍后在任务历史查看"
message.error(detail)
} finally {
setFinishing(false)
}
}, [
finishing,
isBatch,
finalVideo,
generatedVideos,
batchTasks,
previewCovers,
previewTitles,
titleSettings.title,
coverSettings,
navigate,
])
/* ── 布局 class ── */
const layoutClassName = "xx-generate-layout full-width"
@@ -480,9 +556,6 @@ const GeneratePage: React.FC = () => {
onPreviewCountChange={setPreviewCount}
videoRatio={videoRatio as "9:16" | "16:9"}
onVideoRatioChange={(r) => setVideoRatio(r)}
selectedScript={selectedScript}
ttsVoiceId={ttsVoiceId}
ttsVoiceSource={ttsVoiceSource}
onSelectedVoiceChange={setSelectedVoice}
generating={generating}
generated={generated}
@@ -565,12 +638,6 @@ const GeneratePage: React.FC = () => {
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
🔗 分享
</button>
<button
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={() => navigate("/app/products")}
>
📁 前往成片库
</button>
</div>
</div>
</div>
@@ -590,6 +657,8 @@ const GeneratePage: React.FC = () => {
pointsInsufficient={insufficientPoints}
insufficientReason={pointsEstimate.reason}
onRecharge={() => navigate("/app/points/recharge")}
onFinish={handleFinish}
finishing={finishing}
/>
</div>
</div>
@@ -5,7 +5,7 @@
* 步骤 1~3:上一步 / 下一步
* 步骤 4(确认生成/进度):未开始 →「✨ 确认生成视频」;生成中 →「⏳ 视频渲染中…」;
* 失败 →「🔄 重新生成」;全部完成 →「下一步:选择封面 →」
* 步骤 5(选择封面):仅上一步,无主按钮
* 步骤 5(选择封面):上一步 + 完成按钮
*/
import React from "react"
import { Tooltip } from "antd"
@@ -32,6 +32,10 @@ export interface GenerateStepActionsProps {
freeClipsUsedThisTime?: number
/** 前往充值 */
onRecharge?: () => void
/** Step5 点击完成(保存入库并跳转) */
onFinish?: () => void | Promise<void>
/** Step5 保存中 */
finishing?: boolean
}
const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
@@ -48,6 +52,8 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
insufficientReason,
freeClipsUsedThisTime,
onRecharge,
onFinish,
finishing,
}) => {
const renderPrimaryButton = () => {
/* 步骤 1~3:上一步 / 下一步 */
@@ -126,8 +132,18 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
)
}
/* 步骤 5(封面,最后一步):无主按钮 */
return null
/* 步骤 5(封面,最后一步):完成按钮 */
return (
<button
className="xx-btn xx-btn-primary"
onClick={() => {
if (onFinish && !finishing) void onFinish()
}}
disabled={finishing}
>
{finishing ? "⏳ 保存中…" : "✅ 完成"}
</button>
)
}
return (
@@ -6,7 +6,6 @@
import React from "react"
import type { CoverConfig } from "../types/cover"
import type { TitleSettings } from "../types"
import type { ScriptItem } from "@/api/scripts"
import Step1EditMode from "./Step1EditMode"
import type { EditMode } from "./Step1EditMode"
import Step2MaterialSelect from "../components/Step2MaterialSelect"
@@ -90,10 +89,6 @@ export interface GenerateStepContentProps {
previewCovers: string[]
onPreviewCoversChange: (urls: string[]) => void
selectedVariantIds?: number[]
/* ── 摘要信息(#1970 Step4 展示用) ── */
selectedScript: ScriptItem | null
ttsVoiceId: string
ttsVoiceSource: "preset" | "clone"
}
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
@@ -147,9 +142,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
previewCovers,
onPreviewCoversChange,
selectedVariantIds,
selectedScript,
ttsVoiceId,
ttsVoiceSource,
} = props
switch (currentStep) {
@@ -208,40 +200,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
case 4:
return (
<div className="xx-form-section">
{/* 配置摘要(#1970) */}
<div
style={{
padding: 14,
background: "#f9fafb",
borderRadius: 8,
marginBottom: 16,
fontSize: 13,
lineHeight: 1.8,
color: "#374151",
}}
>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 6, color: "#111" }}>
📋 生成配置
</div>
<div>🎬 剪辑模式:{editMode === "random" ? "🎲 随机混剪" : "📖 叙事剪辑"}</div>
{editMode === "random" ? (
<div>🎙️ 配音来源:配音库音频</div>
) : (
<>
<div>📝 文案:{selectedScript?.title ?? "未选择"}</div>
<div>
🎙️ 合成配音音色:
{ttsVoiceId
? `${ttsVoiceSource === "clone" ? "克隆音色" : "系统音色"}(${ttsVoiceId.slice(0, 8)}...)`
: "未选择"}
</div>
</>
)}
<div>📱 视频比例:{videoRatio}</div>
<div>🎯 智能降重:{dedupEnabled ? "已开启" : "已关闭"}</div>
{previewCount > 1 && <div>📦 生成数量:{previewCount} 个</div>}
</div>
{previewCount > 1 ? (
<BatchGenerationGrid
tasks={batchTasks}
@@ -53,7 +53,7 @@ interface UseBatchCoversOptions {
}
export function useBatchCovers({
selectedTemplate,
selectedTemplate: _selectedTemplate,
generatedVideos,
titles,
titleStyle,
@@ -102,7 +102,7 @@ export function useBatchCovers({
addBusy(index)
try {
const titleText = titles[index] || ""
const response = await generateCover(selectedTemplate, {
const response = await generateCover("default", {
generated_video_id: target.id,
video_url: target.file_url || target.download_url || "",
cover_type: "ai_frame",
@@ -166,7 +166,7 @@ export function useBatchCovers({
removeBusy(index)
}
},
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover, addBusy, removeBusy],
[generatedVideos, titles, titleStyle, patchCover, addBusy, removeBusy],
)
/** 为第 index 个视频上传自定义封面 */
@@ -19,7 +19,7 @@ import {
interface UseStep6CoverProps {
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
/** 当前选中的模板 ID */
/** 当前选中的视频模板 ID(封面模板由本地 state 管理) */
selectedTemplate?: string
/** Step4 标题设置,用于封面叠加标题 */
titleSettings?: TitleSettings
@@ -30,7 +30,7 @@ interface UseStep6CoverProps {
export function useStep6Cover({
coverSettings,
onCoverSettingsChange,
selectedTemplate = "",
selectedTemplate: _selectedTemplate = "",
titleSettings,
generatedVideos,
}: UseStep6CoverProps) {
@@ -79,7 +79,8 @@ export function useStep6Cover({
return
}
if (!selectedTemplate) {
const activeCoverTemplateId = selectedTemplateId || "default"
if (!activeCoverTemplateId) {
message.error("请先选择模板")
return
}
@@ -95,7 +96,7 @@ export function useStep6Cover({
}, 300000)
try {
const response = await generateCover(selectedTemplate, {
const response = await generateCover(activeCoverTemplateId, {
generated_video_id: finalVideo.id,
video_url: finalVideo.file_url || finalVideo.download_url || "",
cover_type: "ai_frame",
@@ -156,7 +157,7 @@ export function useStep6Cover({
setGenerating(false)
}
}, [
selectedTemplate,
selectedTemplateId,
finalVideo,
coverSettings,
onCoverSettingsChange,