fix(ai-avatar): 恢复原始UI样式,页面拆分为两步骤仅做UI调整
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m5s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m28s
CI/CD Pipeline / Integration Tests (push) Successful in 1m33s
CI/CD Pipeline / Validate - Style (push) Successful in 2m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m7s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m38s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m29s
CI/CD Pipeline / Validate - Security (push) Successful in 5m50s
CI/CD Pipeline / Unit Tests (push) Successful in 7m46s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 145h59m33s
CI/CD Pipeline / CI Gate (push) Failing after 145h59m36s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 145h59m36s
CI/CD Pipeline / Build Production API Image (push) Failing after 145h59m36s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 146h6m14s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 146h6m13s
CI/CD Pipeline / Frontend Lint (push) Failing after 146h7m22s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 146h6m14s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 146h7m26s
CI/CD Pipeline / PR Build Web Image (push) Failing after 146h7m26s
CI/CD Pipeline / PR Build API Image (push) Failing after 146h7m26s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 146h7m27s
CI/CD Pipeline / Deploy Production (push) Failing after 146h34m20s
CI/CD Pipeline / Build Production Web Image (push) Failing after 146h34m23s

- 恢复 AiAvatar.css 为原始版本(1178行逐字节一致),仅在末尾追加70行步骤导航/分步面板宽度样式
- 保持原始横向 flex 布局与全部原有 class/样式,撤销上一版纵向堆叠改动
- 步骤1:出镜视频/配音库/文案(3个横向面板)+ 下一步按钮
- 步骤2:对口型预览(含插入画面)/标题配置/封面&生成(3个横向面板)+ 上一步按钮
- PanelScriptAndLipsync 拆为 PanelScript + PanelLipsyncPreview,业务逻辑/props/弹窗全部原样保留
- 不改任何功能,不动其他页面
This commit is contained in:
灵应
2026-09-10 13:15:38 +08:00
committed by xiaoxia
parent 6c77b066de
commit 7dc2954d8f
4 changed files with 1209 additions and 571 deletions
File diff suppressed because it is too large Load Diff
+90 -198
View File
@@ -1,7 +1,7 @@
/**
* AI数字人 — 主页面(两步骤版)
* AI数字人 — 主页面(v3 两步骤版)
* 步骤1:出镜视频 / 配音库 / 文案
* 步骤2:插入画面 / 对口型预览 / 标题配置 / 封面&生成
* 步骤2:对口型预览(含插入画面)/ 标题配置 / 封面&生成
*/
import React, { useState, useCallback, useEffect, useRef } from "react"
import { message } from "antd"
@@ -30,17 +30,11 @@ import {
} from "./utils/contract"
/** 面板折叠状态 */
type PanelKey =
| "video"
| "voice"
| "script"
| "lipsync"
| "title"
| "cover"
type PanelKey = "video" | "voice" | "script" | "lipsync" | "title" | "cover"
const AiAvatarPage: React.FC = () => {
const state = useAiAvatar()
const [currentStep, setCurrentStep] = useState(1)
const [currentStep, setCurrentStep] = useState<1 | 2>(1)
const [collapsed, setCollapsed] = useState<Record<PanelKey, boolean>>({
video: false,
voice: false,
@@ -52,11 +46,11 @@ const AiAvatarPage: React.FC = () => {
/* ── 对口型生成弹窗 ── */
const [showLipsyncModal, setShowLipsyncModal] = useState(false)
const [lipsyncStatus, setLipsyncStatus] = useState<
"generating" | "completed" | "failed"
>("generating")
const [lipsyncStatus, setLipsyncStatus] = useState<"generating" | "completed" | "failed">(
"generating",
)
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
/* ─ 智能封面加载态 ── */
/* ── 智能封面加载态 ── */
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
/* ── 对口型轮询 ── */
@@ -67,24 +61,25 @@ const AiAvatarPage: React.FC = () => {
}, [])
/* ── 步骤切换 ── */
const canGoToStep2 = useCallback(() => {
return state.selectedVideo && state.selectedVoice && state.scriptText.trim()
}, [state.selectedVideo, state.selectedVoice, state.scriptText])
const handleNextStep = useCallback(() => {
if (!canGoToStep2()) {
message.warning("请先完成出镜视频、配音和文案的选择")
const missing: string[] = []
if (!state.selectedVideo) missing.push("出镜视频")
if (!state.selectedVoice) missing.push("配音")
if (!state.scriptText.trim()) missing.push("文案")
if (missing.length > 0) {
message.warning(`请先完成${missing.join("、")}`)
return
}
setCurrentStep(2)
}, [canGoToStep2])
}, [state.selectedVideo, state.selectedVoice, state.scriptText])
const handlePrevStep = useCallback(() => {
setCurrentStep(1)
}, [])
/* ── 对口型 ─ */
/* ── 对口型 ── */
const handleGenerateLipsync = useCallback(async () => {
// ② 缺项明确提示(#1809):不再静默 return
const video = state.selectedVideo
const voice = state.selectedVoice
const text = state.scriptText.trim()
@@ -97,10 +92,12 @@ const AiAvatarPage: React.FC = () => {
return
}
try {
// 显示生成弹窗
setShowLipsyncModal(true)
setLipsyncStatus("generating")
setLipsyncErrorMessage("")
// ① 先按素材 id 拿 file_url(#1809 补充:对齐后端新参数 video_url)
console.log("[对口型] 开始生成:", {
videoId: video.id,
voiceId: voice.voice_id,
@@ -119,20 +116,19 @@ const AiAvatarPage: React.FC = () => {
message.error("获取出镜视频播放地址失败,请重新选择素材")
return
}
// ② 模式A TTS直生:video_url + voice_id + script_text,语速/情绪英文枚举透传(#1822)
const payload = {
voice_id: voice.voice_id,
script_text: state.scriptText,
video_url: videoUrl,
speed: state.speed,
emotion: normalizeEmotion(state.emotion),
speed: state.speed, // 语速 0.5~2.0
emotion: normalizeEmotion(state.emotion), // natural/excited/calm/friendly
}
console.log("[对口型] createLipsyncJob 请求:", payload)
const job = await createLipsyncJob(payload)
console.log("[对口型] createLipsyncJob 响应:", {
id: job.id,
status: job.status,
})
console.log("[对口型] createLipsyncJob 响应:", { id: job.id, status: job.status })
state.setLipsyncJob(job)
// 开始轮询
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
lipsyncTimerRef.current = setInterval(async () => {
try {
@@ -153,9 +149,7 @@ const AiAvatarPage: React.FC = () => {
} else if (updated.status === "failed") {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
setLipsyncStatus("failed")
setLipsyncErrorMessage(
updated.error_message || "对口型生成失败",
)
setLipsyncErrorMessage(updated.error_message || "对口型生成失败")
}
} catch (err) {
console.error("[对口型] 轮询错误:", err)
@@ -168,19 +162,12 @@ const AiAvatarPage: React.FC = () => {
message: err instanceof Error ? err.message : String(err),
})
setShowLipsyncModal(false)
message.error(
err instanceof Error ? err.message : "对口型任务提交失败,请重试",
)
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
state.selectedVideo,
state.selectedVoice,
state.scriptText,
state.speed,
state.emotion,
])
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion])
// 取消对口型生成
const handleCancelLipsync = useCallback(() => {
if (lipsyncTimerRef.current) {
clearInterval(lipsyncTimerRef.current)
@@ -191,6 +178,7 @@ const AiAvatarPage: React.FC = () => {
setLipsyncErrorMessage("")
}, [])
// 清理轮询
useEffect(() => {
return () => {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
@@ -199,6 +187,7 @@ const AiAvatarPage: React.FC = () => {
/* ── 生成视频 ── */
const handleGenerate = useCallback(async () => {
// ② 前置条件提示(#1809)
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") {
message.warning("请先生成对口型视频,待对口型完成后再提交渲染")
return
@@ -209,19 +198,16 @@ const AiAvatarPage: React.FC = () => {
lipsync_job_id: state.lipsyncJob.id,
script_id: state.script?.id,
b_roll_segments: state.bRollSegments as never,
// 字段映射:build_title_drawtext_filter 真实口径 text/font_size/font_color/position/...
title_config: buildTitleConfigPayload(state.titleConfig),
cover_config: buildCoverConfigPayload(
state.coverConfig,
state.coverConfig.smart_cover_url,
),
// cover_config:智能封面 cover_url + 截帧 timestamp
cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url),
resolution: state.resolution,
})
message.success("渲染任务已提交,可在视频管理中查看进度")
} catch (err) {
console.error("渲染任务提交失败:", err)
message.error(
err instanceof Error ? err.message : "渲染任务提交失败,请重试",
)
message.error(err instanceof Error ? err.message : "渲染任务提交失败,请重试")
} finally {
state.setIsGenerating(false)
}
@@ -235,8 +221,9 @@ const AiAvatarPage: React.FC = () => {
state.resolution,
])
/* ── 智能封面 ── */
/* ── 智能封面:调后端 MediaKit 选帧接口(#1822) ── */
const handleSmartCover = useCallback(async () => {
// 基于对口型成片抽帧,必须先完成对口型
const videoUrl = state.lipsyncJob?.output_video_url
if (state.lipsyncJob?.status !== "completed" || !videoUrl) {
message.warning("请先生成对口型视频,完成后再智能获取封面")
@@ -258,9 +245,7 @@ const AiAvatarPage: React.FC = () => {
}
} catch (err) {
console.error("智能封面生成失败:", err)
message.error(
err instanceof Error ? err.message : "智能封面生成失败,请重试",
)
message.error(err instanceof Error ? err.message : "智能封面生成失败,请重试")
} finally {
setSmartCoverLoading(false)
}
@@ -281,36 +266,26 @@ const AiAvatarPage: React.FC = () => {
return (
<div className="aa-page">
<div className="aa-page-header">
<h1>AI 数字人</h1>
<h1>AI数字人</h1>
</div>
{/* 步骤指示器 */}
<div className="aa-steps">
<div className={`aa-step ${currentStep >= 1 ? "active" : ""}`}>
<span className="aa-step__number">1</span>
<span className="aa-step__label">视频 / 配音 / 文案</span>
</div>
<div className="aa-step__connector" />
<div className={`aa-step ${currentStep >= 2 ? "active" : ""}`}>
<span className="aa-step__number">2</span>
<span className="aa-step__label">
对口型 / 标题 / 封面 / 生成
</span>
</div>
{/* 步骤切换导航条 */}
<div className="aa-step-nav">
<span className={`aa-step-nav__item${currentStep === 1 ? " active" : ""}`}>
1. 视频 / 配音 / 文案
</span>
<span className={`aa-step-nav__item${currentStep === 2 ? " active" : ""}`}>
2. 对口型 / 标题 / 封面 / 生成
</span>
</div>
<div className="aa-page-body">
{/* ════ 步骤 1:出镜视频 / 配音库 / 文案 ═══ */}
{/* ════ 步骤 1:出镜视频 / 配音库 / 文案 ════ */}
{currentStep === 1 && (
<>
{/* 面板 1:出镜视频 */}
<div
className={`aa-panel${collapsed.video ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("video")}
>
{/* 面板1:出镜视频 */}
<div className={`aa-panel aa-panel--s1${collapsed.video ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("video")}>
<span className="aa-panel__title">出镜视频</span>
<span className="aa-panel__toggle">▼</span>
</div>
@@ -324,14 +299,9 @@ const AiAvatarPage: React.FC = () => {
</div>
</div>
{/* 面板 2:配音库 */}
<div
className={`aa-panel${collapsed.voice ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("voice")}
>
{/* 面板2:配音库 */}
<div className={`aa-panel aa-panel--s1${collapsed.voice ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("voice")}>
<span className="aa-panel__title">配音库</span>
<span className="aa-panel__toggle">▼</span>
</div>
@@ -351,15 +321,10 @@ const AiAvatarPage: React.FC = () => {
</div>
</div>
{/* 面板 3:文案 */}
<div
className={`aa-panel${collapsed.script ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("script")}
>
<span className="aa-panel__title">文案</span>
{/* 面板3:文案 */}
<div className={`aa-panel aa-panel--s1-wide${collapsed.script ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("script")}>
<span className="aa-panel__title">文案 & 对口型</span>
<span className="aa-panel__toggle">▼</span>
</div>
<div className="aa-panel__body">
@@ -368,35 +333,23 @@ const AiAvatarPage: React.FC = () => {
onScriptTextChange={state.setScriptText}
onOpenScriptModal={() => state.setShowScriptModal(true)}
/>
<div className="aa-step-btn-row">
<button type="button" className="aa-btn aa-btn--primary" onClick={handleNextStep}>
下一步 →
</button>
</div>
</div>
</div>
{/* 步骤 1 底部按钮 */}
<div className="aa-step-actions">
<button
className="aa-btn aa-btn--primary"
onClick={handleNextStep}
>
下一步
</button>
</div>
</>
)}
{/* ════ 步骤 2:插入画面 / 对口型预览 / 标题配置 / 封面&生成 ═══ */}
{/* ════ 步骤 2:对口型预览(含插入画面)/ 标题配置 / 封面&生成 ════ */}
{currentStep === 2 && (
<>
{/* 面板 4:插入画面 & 对口型预览 */}
<div
className={`aa-panel${collapsed.lipsync ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("lipsync")}
>
<span className="aa-panel__title">
对口型预览
</span>
{/* 面板:对口型预览 + 插入画面 */}
<div className={`aa-panel aa-panel--s2-wide${collapsed.lipsync ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("lipsync")}>
<span className="aa-panel__title">对口型预览</span>
<span className="aa-panel__toggle">▼</span>
</div>
<div className="aa-panel__body">
@@ -407,36 +360,28 @@ const AiAvatarPage: React.FC = () => {
onOpenBRollModal={() => state.setShowBRollModal(true)}
onRemoveBRoll={state.removeBRollSegment}
/>
<div className="aa-step-btn-row">
<button type="button" className="aa-btn" onClick={handlePrevStep}>
← 上一步
</button>
</div>
</div>
</div>
{/* 面板 5:标题配置 */}
<div
className={`aa-panel${collapsed.title ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("title")}
>
{/* 面板4:标题配置 */}
<div className={`aa-panel aa-panel--s2${collapsed.title ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("title")}>
<span className="aa-panel__title">标题配置</span>
<span className="aa-panel__toggle">▼</span>
</div>
<div className="aa-panel__body">
<PanelTitleConfig
titleConfig={state.titleConfig}
onUpdate={state.updateTitleConfig}
/>
<PanelTitleConfig titleConfig={state.titleConfig} onUpdate={state.updateTitleConfig} />
</div>
</div>
{/* 面板 6:封面 & 生成 */}
<div
className={`aa-panel${collapsed.cover ? " collapsed" : ""}`}
>
<div
className="aa-panel__header"
onClick={() => togglePanel("cover")}
>
{/* 面板5:封面 & 生成 */}
<div className={`aa-panel aa-panel--s2${collapsed.cover ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("cover")}>
<span className="aa-panel__title">封面 & 生成</span>
<span className="aa-panel__toggle">▼</span>
</div>
@@ -444,16 +389,11 @@ const AiAvatarPage: React.FC = () => {
<PanelCoverAndGenerate
coverConfig={state.coverConfig}
onCoverConfigChange={(partial) =>
state.setCoverConfig((prev) => ({
...prev,
...partial,
}))
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
}
onSmartCover={handleSmartCover}
smartCoverLoading={smartCoverLoading}
canSmartCover={
state.lipsyncJob?.status === "completed"
}
canSmartCover={state.lipsyncJob?.status === "completed"}
resolution={state.resolution}
onResolutionChange={state.setResolution}
isGenerating={state.isGenerating}
@@ -462,13 +402,6 @@ const AiAvatarPage: React.FC = () => {
/>
</div>
</div>
{/* 步骤 2 底部按钮 */}
<div className="aa-step-actions">
<button className="aa-btn" onClick={handlePrevStep}>
上一步
</button>
</div>
</>
)}
</div>
@@ -511,10 +444,7 @@ const AiAvatarPage: React.FC = () => {
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
<div className="aa-modal__header">
<span className="aa-modal__title">对口型生成</span>
<button
className="aa-modal__close"
onClick={handleCancelLipsync}
>
<button className="aa-modal__close" onClick={handleCancelLipsync}>
✕
</button>
</div>
@@ -530,22 +460,10 @@ const AiAvatarPage: React.FC = () => {
{lipsyncStatus === "generating" && (
<>
<div className="aa-lipsync-spinner" />
<div
style={{
marginTop: 20,
fontSize: 15,
color: "#1a1a2e",
}}
>
<div style={{ marginTop: 20, fontSize: 15, color: "#1a1a2e" }}>
对口型视频生成中…
</div>
<div
style={{
marginTop: 8,
fontSize: 13,
color: "#8c8ca1",
}}
>
<div style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1" }}>
请勿关闭页面,完成后将自动提示
</div>
</>
@@ -553,13 +471,7 @@ const AiAvatarPage: React.FC = () => {
{lipsyncStatus === "completed" && (
<>
<div style={{ fontSize: 48 }}>✅</div>
<div
style={{
marginTop: 16,
fontSize: 15,
color: "#1a1a2e",
}}
>
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
对口型视频生成完成
</div>
</>
@@ -567,23 +479,11 @@ const AiAvatarPage: React.FC = () => {
{lipsyncStatus === "failed" && (
<>
<div style={{ fontSize: 48 }}>❌</div>
<div
style={{
marginTop: 16,
fontSize: 15,
color: "#1a1a2e",
}}
>
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
对口型生成失败
</div>
{lipsyncErrorMessage && (
<div
style={{
marginTop: 8,
fontSize: 13,
color: "#ff4d4f",
}}
>
<div style={{ marginTop: 8, fontSize: 13, color: "#ff4d4f" }}>
{lipsyncErrorMessage}
</div>
)}
@@ -592,10 +492,7 @@ const AiAvatarPage: React.FC = () => {
</div>
<div className="aa-modal__footer">
{lipsyncStatus === "generating" && (
<button
className="aa-btn aa-btn--danger"
onClick={handleCancelLipsync}
>
<button className="aa-btn aa-btn--danger" onClick={handleCancelLipsync}>
取消生成
</button>
)}
@@ -663,17 +560,12 @@ const ScriptSelectModalLazy: React.FC<{
) : (
<div className="aa-script-list">
{filtered.map((s) => (
<div
key={s.id}
className="aa-script-item"
onClick={() => onSelect(s)}
>
<div key={s.id} className="aa-script-item" onClick={() => onSelect(s)}>
<span className="aa-script-item__icon">📄</span>
<div className="aa-script-item__info">
<div className="aa-script-item__title">{s.title}</div>
<div className="aa-script-item__meta">
{s.char_count}字 ·{" "}
{new Date(s.created_at).toLocaleDateString()}
{s.char_count}字 · {new Date(s.created_at).toLocaleDateString()}
</div>
</div>
</div>
@@ -43,7 +43,7 @@ export function PanelLipsyncPreview({
: "对口型生成中…"
return (
<div className="aa-lipsync-preview-panel">
<div className="aa-script-lipsync">
{/* ── B-roll 画面 ── */}
<div className="aa-lipsync-section">
<div className="aa-lipsync-section__title">
@@ -20,7 +20,7 @@ export function PanelScript({
const [scriptTab, setScriptTab] = useState<ScriptTab>("library")
return (
<div className="aa-script-panel">
<div className="aa-script-lipsync">
{/* ── Tab 切换 ── */}
<div className="aa-script-tabs">
<button