Files
xiaoxia-saas/apps/web/src/pages/generate/GeneratePage.tsx
T
xiaoxia da53b6d175
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
refactor: GeneratePage Phase 1
refactor: GeneratePage Phase 1 — 抽离常量/类型/UI组件(3036→2682行)
2026-07-24 11:01:41 +08:00

2681 lines
97 KiB
TypeScript

/**
* 智能剪辑页面 — V21 原型 1:1 还原
* 5 步向导:选择模板 → 选择素材 → 选择标题 → 选择配音 → 确认生成
* 左右布局:左侧 generate-form + 右侧 generate-preview
* 保留所有现有业务逻辑(API 调用、URL 参数、CloneModal、TTS 等)
*/
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"
import { useQuery, useMutation } from "@tanstack/react-query"
import { Typography, message, Select, Modal } from "antd"
import {
AudioOutlined,
ThunderboltOutlined,
CheckCircleFilled,
CloseCircleOutlined,
LoadingOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
SaveOutlined,
PlusOutlined,
MinusOutlined,
CloseOutlined,
} from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import { getAssets, getAssetLibraries } from "@/api/assets"
import {
generateEditPlan,
updateEditPlan,
getGenerationTaskResults,
getGenerationStatus,
getEditPlan,
} from "@/api/template-editor"
import type { GeneratedVideo, EditPlanConfig, TitleConfig } from "@/api/template-editor"
import type { CoverConfig } from "../editing-planner/types"
import { getEditingTemplates } from "@/api/editing-planner"
import { getTitles } from "@/api/titles"
import { fetchPresetVoices } from "@/api/voices"
import type { PresetVoiceItem } from "@/api/voices"
import { formatDuration } from "@/api/voice-clone"
import type { VoiceClone } from "@/api/voice-clone"
import CloneModal from "@/components/voice/CloneModal"
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import { getTags, createTag } from "@/api/tags"
import { useCloneProgress } from "@/hooks/useCloneProgress"
import { useSearchParams, useNavigate } from "react-router-dom"
import GenerateHeader from "./components/GenerateHeader"
import GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateResultPanel from "./components/GenerateResultPanel"
import {
CLONE_STATUS_CONFIG,
MODE_GRADIENTS,
VOICE_GENDER_ICON,
POSITION_OPTIONS,
FONT_OPTIONS,
TITLE_PRESETS,
COVER_MODE_LABELS,
COVER_MODE_ICONS,
DEFAULT_COVER_SETTINGS,
SMART_MATCH_REASONS,
AI_TITLE_TEMPLATES,
} from "./constants"
import type { TitleSettings } from "./types"
import "./generate.css"
const { Text } = Typography
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "bottom",
font: "思源黑体",
size: 28,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
}
function getActivePreset(settings: TitleSettings): string | null {
for (const p of TITLE_PRESETS) {
if (
settings.size === p.style.size &&
settings.color === p.style.color &&
settings.bold === p.style.bold &&
settings.italic === p.style.italic &&
settings.stroke === p.style.stroke &&
settings.shadow === p.style.shadow
) {
return p.key
}
}
return null
}
/* ================================================================
组件
================================================================ */
const GeneratePage: React.FC = () => {
const navigate = useNavigate()
/* ── 步骤状态 ── */
const [currentStep, setCurrentStep] = useState(1)
/* ── 模板(从 API 加载) ── */
const [selectedTemplate, setSelectedTemplate] = useState("")
const { data: userTemplates = [] } = useQuery({
queryKey: ["generate-templates"],
queryFn: () => getEditingTemplates(),
staleTime: 60_000,
})
/* 模板加载完成后自动选中第一个 */
useEffect(() => {
if (userTemplates.length > 0 && !selectedTemplate) {
setSelectedTemplate(userTemplates[0].id)
}
}, [userTemplates, selectedTemplate])
/* ── 素材 ── */
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
/* 素材选择模式:手动选择 / 自动匹配 */
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
/* 智能素材匹配:用户描述输入 */
const [smartMatchInput, setSmartMatchInput] = useState("")
/* 智能素材匹配:是否正在匹配中 */
const [smartMatching, setSmartMatching] = useState(false)
/* 智能素材匹配:推荐结果列表 */
const [smartMatchedResults, setSmartMatchedResults] = useState<
Array<{ asset: AssetItem; matchScore: number; matchReason: string }>
>([])
/* 智能素材匹配:已选择的素材ID(auto模式下使用) */
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
/* 智能素材匹配:是否已执行过匹配 */
const [hasMatched, setHasMatched] = useState(false)
/* ── 智能标题生成 ── */
const [aiTitleInput, setAiTitleInput] = useState("")
const [aiTitleGenerating, setAiTitleGenerating] = useState(false)
const [aiTitleResults, setAiTitleResults] = useState<
Array<{ title: string; highlight: string; style: "catchy" | "emotional" | "informative" }>
>([])
const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false)
/* ── 智能配音推荐 ── */
const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false)
const [voiceRecommendations, setVoiceRecommendations] = useState<string[]>([])
const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false)
/* ── 标题设置 ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
/* ── 封面设置 ── */
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
const { data: userTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: () => getTitles(),
staleTime: 30_000,
})
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 */
useEffect(() => {
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
if (tpl?.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: tpl.title_config!.ai_auto_select,
title: tpl.title_config!.content || prev.title,
position: tpl.title_config!.position || prev.position,
font: tpl.title_config!.font_preset || prev.font,
size: tpl.title_config!.font_size || prev.size,
color: tpl.title_config!.font_color || prev.color,
}))
}
if (tpl?.cover_config) {
setCoverSettings((prev) => ({
...prev,
enabled: tpl.cover_config!.enabled ?? prev.enabled,
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
}))
}
}, [selectedTemplate, userTemplates])
/* ── 配音 ── */
const [selectedVoice, setSelectedVoice] = useState<string>("")
const [playingVoice, setPlayingVoice] = useState<string | null>(null)
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
const [customVoiceText, setCustomVoiceText] = useState("")
/* ── 生成数量 ── */
const [generateCount, setGenerateCount] = useState(1)
/* ── 克隆声音 ── */
const [selectedClonedVoice, setSelectedClonedVoice] = useState<string>("")
const [cloneModalOpen, setCloneModalOpen] = useState(false)
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
/* ── 高级设置(隐藏但保留) ── */
const [videoRatio] = useState("16:9")
const [duration] = useState(30)
const [style] = useState("business")
const [autoSubtitles] = useState(true)
const [bgm] = useState(true)
/* ── 生成状态 ── */
const [generating, setGenerating] = useState(false)
const [progress, setProgress] = useState(0)
const [generated, setGenerated] = useState(false)
const [generateError, setGenerateError] = useState<string | null>(null)
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
const [previewModalOpen, setPreviewModalOpen] = useState(false)
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined)
const audioRef = useRef<HTMLAudioElement | null>(null)
/* ── URL 参数:从模板编辑器跳转过来时携带 edit_plan_id + plan_config ── */
const [searchParams] = useSearchParams()
const editPlanId = searchParams.get("edit_plan_id")
const planConfigStr = searchParams.get("plan_config")
/** 解析 plan_config 并自动填充表单 */
useEffect(() => {
if (!planConfigStr) return
try {
const config = JSON.parse(planConfigStr) as {
title_config?: { content?: string; ai_auto_select?: boolean }
subtitle_config?: { enabled?: boolean }
bgm_config?: { enabled?: boolean; music_id?: string }
mode?: string
total_duration?: number
segments?: Array<{ media_asset_id?: string; material_type?: string }>
}
if (config.title_config) {
const tc = config.title_config as TitleConfig
setTitleSettings((prev) => ({
...prev,
title: tc.content || "",
aiAutoSelect: tc.ai_auto_select || false,
position: tc.position || prev.position,
font: tc.font_preset || prev.font,
size: tc.font_size || prev.size,
color: tc.font_color || prev.color,
}))
}
if (config.segments && config.segments.length > 0) {
const assetIds = config.segments
.map((s) => s.media_asset_id)
.filter((id): id is string => !!id)
if (assetIds.length > 0) {
setSelectedMaterials(assetIds)
}
}
} catch (err) {
console.warn("解析 plan_config 失败:", err)
}
}, [planConfigStr])
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
useEffect(() => {
if (!editPlanId || planConfigStr) return
const loadPlanConfig = async () => {
try {
const plan = await getEditPlan(editPlanId)
if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name }))
const cfg = plan.config
if (cfg?.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content || prev.title,
position: cfg.title_config!.position || prev.position,
font: cfg.title_config!.font_preset || prev.font,
size: cfg.title_config!.font_size || prev.size,
color: cfg.title_config!.font_color || prev.color,
}))
}
if (cfg?.cover_config) {
const cc = cfg.cover_config as CoverConfig
setCoverSettings((prev) => ({
...prev,
enabled: cc.enabled ?? prev.enabled,
mode: cc.mode || prev.mode,
frame_time: cc.frame_time ?? prev.frame_time,
upload_url: cc.upload_url || prev.upload_url,
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
}))
}
if (cfg?.asset_ids) {
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
}
} catch (err) {
console.warn("加载模板草稿配置失败:", err)
}
}
loadPlanConfig()
}, [editPlanId, planConfigStr])
/* ── 预置音色 API ── */
const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
})
const presetVoices: PresetVoiceItem[] = useMemo(
() => presetVoicesData?.items ?? [],
[presetVoicesData],
)
/* ── TTS 自定义合成状态 ── */
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null)
const [ttsError, setTtsError] = useState<string | null>(null)
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
/** 合成完成后保留的 job ID,用于"存为素材" */
const [completedTtsJobId, setCompletedTtsJobId] = useState<string | null>(null)
/* ── 存为素材弹窗状态 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false)
const [saveName, setSaveName] = useState("")
const [saveTagIds, setSaveTagIds] = useState<string[]>([])
const [saveNewTag, setSaveNewTag] = useState("")
/* ── 标签列表(用于存为素材弹窗) ── */
const { data: allTags = [] } = useQuery({
queryKey: ["generate-save-tags"],
queryFn: getTags,
staleTime: 30_000,
})
/* ── 素材数据 API ── */
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
})
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
// 自动选中第一个视频库
useEffect(() => {
if (libraries.length > 0 && !selectedLibraryId) {
setSelectedLibraryId(libraries[0].id)
}
}, [libraries, selectedLibraryId])
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
items: AssetItem[]
total: number
}>({
queryKey: ["generate-assets", selectedLibraryId],
queryFn: () => getAssets(selectedLibraryId),
enabled: !!selectedLibraryId,
})
/* ── 智能素材匹配 ── */
const handleSmartMatch = useCallback(async () => {
if (!smartMatchInput.trim()) {
message.warning("请先输入视频内容描述")
return
}
if (materials.items.length === 0) {
message.warning("当前视频库暂无素材")
return
}
setSmartMatching(true)
setHasMatched(true)
// 模拟 AI 匹配延迟
await new Promise((resolve) => setTimeout(resolve, 1500))
// 从素材库中随机选取 5-8 个作为推荐结果
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
const picked = shuffled.slice(0, count)
const results = picked.map((asset, idx) => ({
asset,
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
matchReason:
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
}))
// 按匹配度从高到低排序
results.sort((a, b) => b.matchScore - a.matchScore)
setSmartMatchedResults(results)
// 默认选中匹配度 >= 90 的素材
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
setSmartSelectedIds(
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
)
setSmartMatching(false)
}, [smartMatchInput, materials.items])
const handleToggleSmartSelect = useCallback((assetId: string) => {
setSmartSelectedIds((prev) =>
prev.includes(assetId) ? prev.filter((id) => id !== assetId) : [...prev, assetId],
)
}, [])
const handleRefreshMatch = useCallback(async () => {
if (materials.items.length <= 5) {
message.info("视频库素材较少,无法换一批")
return
}
setSmartMatching(true)
await new Promise((resolve) => setTimeout(resolve, 800))
const remaining = materials.items.filter(
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
)
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
const picked = shuffled.slice(0, count)
const results = picked.map((asset, idx) => ({
asset,
matchScore: Math.round(80 + Math.random() * 19),
matchReason:
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
(Math.random() > 0.5 ? ",节奏明快" : ""),
}))
results.sort((a, b) => b.matchScore - a.matchScore)
setSmartMatchedResults(results)
setSmartSelectedIds([])
setSmartMatching(false)
}, [materials.items, smartMatchedResults])
const handleSelectAllMatched = useCallback(() => {
setSmartSelectedIds(smartMatchedResults.map((r) => r.asset.id))
}, [smartMatchedResults])
const handleClearSmartSelect = useCallback(() => {
setSmartSelectedIds([])
}, [])
/* ── 智能标题生成 ── */
const extractTopic = (text: string): string => {
const keywords = text
.replace(/[,。!?、,.!?]/g, " ")
.split(/\s+/)
.filter(Boolean)
if (keywords.length === 0) return "这个话题"
// 取前3个关键词组合
return keywords.slice(0, 3).join("")
}
const handleGenerateAiTitles = useCallback(async () => {
if (!aiTitleInput.trim()) {
message.warning("请先输入视频描述或关键词")
return
}
setAiTitleGenerating(true)
setHasGeneratedTitles(true)
// 模拟 AI 生成延迟
await new Promise((resolve) => setTimeout(resolve, 1200))
const topic = extractTopic(aiTitleInput)
const results: Array<{
title: string
highlight: string
style: "catchy" | "emotional" | "informative"
}> = []
const styles: Array<"catchy" | "emotional" | "informative"> = [
"catchy",
"emotional",
"informative",
]
styles.forEach((style) => {
const templates = AI_TITLE_TEMPLATES[style]
// 每种风格随机选2个
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
shuffled.forEach((tpl) => {
const title = tpl.replace(/\{topic\}/g, topic)
const highlights = {
catchy: "吸睛标题",
emotional: "情感共鸣",
informative: "知识干货",
}
results.push({
title,
highlight: highlights[style],
style,
})
})
})
// 打乱顺序
results.sort(() => Math.random() - 0.5)
setAiTitleResults(results)
setAiTitleGenerating(false)
}, [aiTitleInput])
const handleSelectAiTitle = useCallback((title: string) => {
setTitleSettings((prev) => ({ ...prev, title, aiAutoSelect: false }))
message.success("已选用此标题")
}, [])
const handleRefreshAiTitles = useCallback(async () => {
if (!aiTitleInput.trim()) return
setAiTitleGenerating(true)
await new Promise((resolve) => setTimeout(resolve, 800))
// 重新生成一批
const topic = extractTopic(aiTitleInput)
const results: typeof aiTitleResults = []
const styles: Array<"catchy" | "emotional" | "informative"> = [
"catchy",
"emotional",
"informative",
]
const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" }
styles.forEach((style) => {
const templates = AI_TITLE_TEMPLATES[style]
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
shuffled.forEach((tpl) => {
results.push({
title: tpl.replace(/\{topic\}/g, topic),
highlight: highlights[style],
style,
})
})
})
results.sort(() => Math.random() - 0.5)
setAiTitleResults(results)
setAiTitleGenerating(false)
}, [aiTitleInput])
/* ── 智能配音推荐 ── */
const handleVoiceRecommend = useCallback(async () => {
if (presetVoices.length === 0) return
setVoiceRecommendLoading(true)
setHasVoiceRecommend(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
// 根据标题内容风格模拟推荐:情感类→温柔女声,知识类→沉稳男声,活力类→阳光少年
const title = titleSettings.title.toLowerCase()
let recommended: string[] = []
const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id)
const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id)
const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id)
if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) {
recommended = femaleVoices.slice(0, 3)
} else if (/教程|知识|科普|干货|讲解|分析/.test(title)) {
recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1))
} else if (/活力|热血|运动|搞笑|有趣/.test(title)) {
recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1))
} else {
// 默认推荐前3个
recommended = presetVoices.slice(0, 3).map((v) => v.voice_id)
}
// 不足3个时补足
if (recommended.length < 3) {
const others = presetVoices
.filter((v) => !recommended.includes(v.voice_id))
.map((v) => v.voice_id)
recommended = recommended.concat(others.slice(0, 3 - recommended.length))
}
setVoiceRecommendations(recommended)
setVoiceRecommendLoading(false)
}, [presetVoices, titleSettings.title])
const handleSelectRecommendedVoice = useCallback((voiceId: string) => {
setVoiceMode("preset")
setSelectedVoice(voiceId)
}, [])
/* ── 生成阶段映射 ── */
const getGenerationPhase = (p: number) => {
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
return { label: "即将完成", icon: "✨" }
}
const handleCloneSuccess = useCallback(
(voice: VoiceClone) => {
addClone(voice)
setCloneModalOpen(false)
message.success("音色克隆成功!")
},
[addClone],
)
/* ── 事件 ── */
const toggleVoicePlay = useCallback(
(voiceId: string, previewUrl: string | null) => {
if (playingVoice === voiceId) {
audioRef.current?.pause()
audioRef.current = null
setPlayingVoice(null)
return
}
audioRef.current?.pause()
if (!previewUrl) {
message.warning("该音色暂无试听音频")
return
}
const audio = new Audio(previewUrl)
audioRef.current = audio
audio.play().catch(() => {
message.error("播放失败,请检查网络")
})
audio.onended = () => {
setPlayingVoice(null)
audioRef.current = null
}
setPlayingVoice(voiceId)
},
[playingVoice],
)
/* ── TTS mutation ── */
const synthesizeMutation = useMutation({
mutationFn: synthesizeSpeech,
onSuccess: (data) => {
setTtsJobId(data.job_id)
message.info("语音合成已提交,等待处理…")
},
onError: () => {
setTtsError("语音合成请求失败,请重试")
},
})
/** 轮询 TTS 任务状态 */
useEffect(() => {
if (!ttsJobId) return
let cancelled = false
let timer: ReturnType<typeof setTimeout>
const poll = async () => {
try {
const status = await getTTSJobStatus(ttsJobId)
if (cancelled) return
if (status.status === "completed") {
setCustomAudioUrl(status.output_audio_url)
setCompletedTtsJobId(ttsJobId)
setTtsJobId(null)
setTtsError(null)
message.success("语音合成完成!")
return
}
if (status.status === "failed" || status.status === "cancelled") {
setTtsError(status.error_message || "语音合成失败")
setTtsJobId(null)
return
}
timer = setTimeout(poll, 2000)
} catch {
if (!cancelled) {
setTtsError("查询合成状态失败")
setTtsJobId(null)
}
}
}
timer = setTimeout(poll, 2000)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [ttsJobId])
/** 触发自定义文本 TTS 合成 */
const handleSynthesizeVoice = useCallback(() => {
if (!customVoiceText.trim()) {
message.warning("请先输入配音文案")
return
}
setTtsError(null)
setCustomAudioUrl(null)
synthesizeMutation.mutate({
text: customVoiceText.trim(),
voice_id: selectedVoice || undefined,
language: "zh-CN",
})
}, [customVoiceText, selectedVoice, synthesizeMutation])
/* ── 存为素材 mutation ── */
const saveToLibraryMutation = useMutation({
mutationFn: (params: { name?: string; tag_ids?: string[] }) =>
saveTtsToLibrary(completedTtsJobId!, params),
onSuccess: () => {
message.success({
content: (
<span>
已保存到配音库!{" "}
<a
onClick={handleGoToLibrary}
style={{
color: "var(--primary-500, #6366f1)",
cursor: "pointer",
}}
>
去视频库查看
</a>
</span>
),
duration: 5,
})
setSaveModalOpen(false)
setSaveName("")
setSaveTagIds([])
setSaveNewTag("")
setCompletedTtsJobId(null)
setCustomAudioUrl(null)
},
onError: (err: Error) => {
message.error(`保存失败:${err.message || "请重试"}`)
},
})
/** 打开存为素材弹窗 */
const handleOpenSaveModal = useCallback(() => {
setSaveName("")
setSaveTagIds([])
setSaveNewTag("")
setSaveModalOpen(true)
}, [])
/** 确认保存 */
const handleConfirmSave = useCallback(() => {
if (!completedTtsJobId) return
saveToLibraryMutation.mutate({
name: saveName.trim() || undefined,
tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined,
})
}, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation])
/** 在弹窗中新增标签(先创建再选中) */
const handleAddTagInModal = useCallback(
async (tagName: string) => {
const trimmed = tagName.trim()
if (!trimmed) return
/* 已在选中列表则跳过 */
const existing = allTags.find((t) => t.name === trimmed)
if (existing) {
if (!saveTagIds.includes(existing.id)) {
setSaveTagIds((prev) => [...prev, existing.id])
}
return
}
try {
const created = await createTag(trimmed)
setSaveTagIds((prev) => [...prev, created.id])
setSaveNewTag("")
} catch {
message.error(`创建标签"${trimmed}"失败`)
}
},
[allTags, saveTagIds],
)
/** 保存成功后跳转到视频库 */
const handleGoToLibrary = useCallback(() => {
navigate("/app/voice-materials")
}, [navigate])
const handleGenerate = useCallback(async () => {
console.log("[handleGenerate] 开始生成, 参数:", {
titleSettings,
selectedTemplate,
selectedMaterials,
voiceMode,
})
if (!titleSettings.title.trim()) {
message.warning("请先选择或输入标题")
return
}
if (materialMode === "manual" && selectedMaterials.length === 0) {
message.warning("请至少选择一个素材")
return
}
if (voiceMode === "clone" && !selectedClonedVoice) {
message.warning("请先选择一个克隆音色")
return
}
setGenerating(true)
setProgress(0)
setGenerated(false)
setGenerateError(null)
try {
const voiceConfig: Pick<
EditPlanConfig,
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
> = {}
if (voiceMode === "preset") {
voiceConfig.voice_id = selectedVoice || undefined
} else if (voiceMode === "clone") {
voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined
} else if (voiceMode === "custom") {
voiceConfig.voice_id = selectedVoice || undefined
if (customAudioUrl) voiceConfig.custom_audio_url = customAudioUrl
if (customVoiceText.trim()) voiceConfig.custom_text = customVoiceText.trim()
}
// 获取或创建草稿(新架构:GET /templates/{templateId}/editor 自动创建)
await getEditPlan(selectedTemplate)
// 更新草稿内容 + 切换到 editing 状态
await updateEditPlan(selectedTemplate, {
name: titleSettings.title.trim(),
config: {
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
},
cover_config: coverSettings,
...voiceConfig,
ratio: videoRatio,
style,
duration,
auto_subtitles: autoSubtitles,
bgm,
generate_count: generateCount,
material_mode: materialMode,
},
total_duration: duration,
status: "editing",
})
await generateEditPlan(selectedTemplate)
const poll = async () => {
try {
const data = await getGenerationStatus(selectedTemplate)
if (data.plan_status === "completed") {
setProgress(100)
setGenerating(false)
setGenerated(true)
// 获取生成的视频结果
if (data.generation_task_id) {
try {
const videos = await getGenerationTaskResults(data.generation_task_id)
setGeneratedVideos(videos)
} catch (err) {
console.error("[获取生成结果失败]", err)
}
}
message.success("视频生成完成!")
return
}
if (data.plan_status === "failed") {
setGenerating(false)
// 提取后端返回的错误详情,便于排查
// 注意:后端返回的 error_message/error/message 可能是对象而非字符串
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const dataAny = data as Record<string, any>
const rawMsg =
dataAny.error_message ||
dataAny.error ||
dataAny.message ||
(data.clips || []).find((c) => c.status === "failed")?.error_message ||
"视频生成失败,请联系管理员或重试"
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等嵌套结构)
const safeExtract = (val: unknown): string => {
if (typeof val === "string") return val
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>
if (typeof obj.message === "string") return obj.message
if (typeof obj.msg === "string") return obj.msg
if (typeof obj.detail === "string") return obj.detail
if (obj.message && typeof obj.message === "object") return safeExtract(obj.message)
return JSON.stringify(val)
}
return String(val ?? "")
}
const errorMsg = safeExtract(rawMsg)
console.error("[生成失败] templateId:", selectedTemplate, "响应:", data)
setGenerateError(errorMsg)
message.error(errorMsg)
return
}
const clips = data.clips || []
const total = clips.length || 1
const done = clips.filter((c: { status: string }) => c.status === "completed").length
setProgress(Math.round((done / total) * 100))
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<
typeof setInterval
>
} catch (pollErr) {
// 轮询接口本身出错(网络/鉴权等),记录并继续轮询一次
console.error("[轮询出错] templateId:", selectedTemplate, pollErr)
progressTimer.current = setTimeout(poll, 3000) as unknown as ReturnType<
typeof setInterval
>
}
}
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<typeof setInterval>
} catch (err: unknown) {
console.error("[handleGenerate] 生成失败:", err)
setGenerating(false)
// 提取 axios 响应中的后端错误信息
const axiosErr = err as {
response?: {
data?: {
message?: string | object
error?: string | object
detail?: string | object
msg?: string | object
}
}
message?: string
}
// 安全提取错误消息:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等)
const extractString = (val: unknown): string => {
if (typeof val === "string") return val
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>
if (typeof obj.message === "string") return obj.message
if (typeof obj.msg === "string") return obj.msg
if (typeof obj.detail === "string") return obj.detail
// 嵌套对象:递归提取
if (typeof obj.message === "object" && obj.message !== null)
return extractString(obj.message)
if (typeof obj.msg === "object" && obj.msg !== null) return extractString(obj.msg)
return JSON.stringify(val)
}
return ""
}
const backendMsg =
extractString(axiosErr.response?.data?.message) ||
extractString(axiosErr.response?.data?.error) ||
extractString(axiosErr.response?.data?.detail) ||
extractString(axiosErr.response?.data?.msg) ||
axiosErr.message ||
""
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", axiosErr)
// 确保 errorMsg 一定是字符串(后端可能返回 {code, message} 嵌套对象)
const safeExtractErr = (val: unknown): string => {
if (typeof val === "string") return val
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>
if (typeof obj.message === "string") return obj.message
if (typeof obj.msg === "string") return obj.msg
if (typeof obj.detail === "string") return obj.detail
// 嵌套对象:递归提取
if (typeof obj.message === "object") return safeExtractErr(obj.message)
return JSON.stringify(val)
}
return String(val ?? "")
}
const rawError = safeExtractErr(backendMsg)
// 将技术错误翻译为用户友好提示(不暴露状态机、字段名等内部概念)
const translateError = (msg: string): string => {
if (!msg) return "生成失败,请检查网络后重试或联系管理员"
// 状态机相关错误
if (msg.includes("editing") || msg.includes("draft") || msg.includes("状态")) {
return "正在准备生成,请稍候再试"
}
// 参数校验错误
if (msg.includes("template_id") || msg.includes("not found") || msg.includes("不存在")) {
return "所选模板或素材不可用,请重新选择"
}
if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) {
return "素材数据异常,请返回视频库重新检查"
}
// 网络/超时
if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) {
return "网络连接超时,请检查网络后重试"
}
// 配额/限制
if (msg.includes("quota") || msg.includes("limit") || msg.includes("exceed")) {
return "已达到生成次数上限,请稍后再试或联系客服"
}
// 兜底:返回原始消息(如果已经是中文人话)或默认提示
if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) return msg
return "生成失败,请稍后重试或联系管理员"
}
const finalMsg = translateError(rawError)
setGenerateError(finalMsg)
message.error(finalMsg)
}
}, [
titleSettings,
selectedMaterials,
selectedVoice,
voiceMode,
selectedClonedVoice,
customAudioUrl,
customVoiceText,
videoRatio,
style,
duration,
autoSubtitles,
bgm,
selectedTemplate,
generateCount,
materialMode,
coverSettings,
smartSelectedIds,
])
/* 重新生成(失败后重试) */
const handleRetryGenerate = useCallback(() => {
setGenerateError(null)
handleGenerate()
}, [handleGenerate])
/* ── 下载视频 ── */
const handleDownload = useCallback(async () => {
if (!generatedVideos.length) return
const video = generatedVideos[0]
try {
// 优先使用 download_url(签名 URL),回退到 file_url
const url = video.download_url || video.file_url
if (url) {
const a = document.createElement("a")
a.href = url
a.download = video.name || "generated-video.mp4"
a.target = "_blank"
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
} catch (err) {
console.error("[下载失败]", err)
message.error("下载失败,请重试")
}
}, [generatedVideos])
/* ── 分享视频 ── */
const handleShare = useCallback(async () => {
if (!generatedVideos.length) return
const video = generatedVideos[0]
const shareUrl = video.file_url || window.location.href
try {
await navigator.clipboard.writeText(shareUrl)
message.success("视频链接已复制到剪贴板")
} catch {
// fallback: 显示 URL 让用户手动复制
message.info(`视频链接: ${shareUrl}`)
}
}, [generatedVideos])
/* ── 步骤导航 ── */
const goNext = useCallback(() => {
if (currentStep === 1 && !selectedTemplate) {
message.warning("请先选择一个模板")
return
}
if (currentStep === 2 && materialMode === "manual" && selectedMaterials.length === 0) {
message.warning("请至少选择一个素材")
return
}
if (currentStep === 2 && materialMode === "auto" && smartSelectedIds.length === 0) {
message.warning("请先进行智能匹配并选择素材")
return
}
if (currentStep === 4 && !titleSettings.title.trim()) {
message.warning("请选择或输入标题")
return
}
if (currentStep < 7) {
setCurrentStep((s) => s + 1)
}
}, [
currentStep,
selectedTemplate,
selectedMaterials.length,
titleSettings,
materialMode,
smartSelectedIds.length,
])
const goPrev = useCallback(() => {
if (currentStep > 1) {
setCurrentStep((s) => s - 1)
}
}, [currentStep])
/* ── 辅助 ── */
const getTemplateName = () =>
userTemplates.find((t) => t.id === selectedTemplate)?.name ?? "未选择"
const getVoiceName = () => {
if (voiceMode === "clone") {
const cv = clonedVoices.find((v) => v.id === selectedClonedVoice)
return cv ? cv.name : "未选择"
}
const pv = presetVoices.find((v) => v.voice_id === selectedVoice)
return pv ? pv.name : "未选择"
}
/* ================================================================
渲染 — 步骤内容
================================================================ */
/** 步骤 1:选择模板 */
const renderStep1 = () => (
<div className="xx-form-section">
<h3>🎨 选择模板</h3>
{userTemplates.length === 0 ? (
<div className="xx-empty-state">
<p>暂无可用模板</p>
<p style={{ fontSize: 13, color: "var(--text-tertiary)" }}>
请先在「模板编辑器」中创建模板
</p>
</div>
) : (
<div className="xx-choice-list">
{userTemplates.map((tpl) => (
<div
key={tpl.id}
className={`xx-choice-item ${selectedTemplate === tpl.id ? "selected" : ""}`}
onClick={() => setSelectedTemplate(tpl.id)}
role="button"
tabIndex={0}
aria-pressed={selectedTemplate === tpl.id}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setSelectedTemplate(tpl.id)
}
}}
>
<span className="xx-choice-check">✓</span>
<div
className="xx-choice-thumb"
style={{
background: MODE_GRADIENTS[tpl.mode] || MODE_GRADIENTS.pip,
}}
>
🎬
</div>
<h4>{tpl.name}</h4>
<p>
{tpl.estimated_duration}s · {tpl.segments.length}片段
</p>
{tpl.tags.length > 0 && (
<div
style={{
display: "flex",
gap: 4,
flexWrap: "wrap",
marginTop: 4,
}}
>
{tpl.tags.map((tag) => (
<span
key={tag}
style={{
fontSize: 11,
padding: "1px 6px",
borderRadius: 6,
background: "var(--bg-secondary)",
color: "var(--text-secondary)",
}}
>
{tag}
</span>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)
/** 步骤 2:选择素材(双模式:手动选择 / 自动匹配) */
const renderStep2 = () => (
<div className="xx-form-section">
<h3>📦 选择素材</h3>
{/* ── 模式切换 Tab ── */}
<div className="xx-material-mode-tabs">
<button
className={`xx-material-mode-tab ${materialMode === "manual" ? "active" : ""}`}
onClick={() => setMaterialMode("manual")}
type="button"
>
手动选择素材
</button>
<button
className={`xx-material-mode-tab ${materialMode === "auto" ? "active" : ""}`}
onClick={() => setMaterialMode("auto")}
type="button"
>
选择视频库自动匹配
</button>
</div>
{/* ── 视频库选择(两种模式共用) ── */}
<div className="xx-form-field" style={{ marginTop: 12 }}>
<label>选择视频库</label>
<select value={selectedLibraryId} onChange={(e) => setSelectedLibraryId(e.target.value)}>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</select>
</div>
{/* ── 手动选择模式 ── */}
{materialMode === "manual" && (
<>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span className="xx-pill xx-pill-ok">已选 {selectedMaterials.length} 个素材</span>
</div>
{/* 素材列表 */}
<div style={{ marginTop: 14 }}>
{materialsLoading ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>加载素材中…</Text>
) : materials.items.length === 0 ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
暂无素材,请先在视频库中上传
</Text>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{materials.items.map((m) => {
const checked = selectedMaterials.includes(m.id)
return (
<label
key={m.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: checked ? "var(--primary-soft, #eef2ff)" : "#f8fafc",
borderRadius: 10,
cursor: "pointer",
border: checked
? "1px solid var(--primary-color, #4f46e5)"
: "1px solid transparent",
transition: "all 0.15s ease",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setSelectedMaterials((prev) =>
prev.includes(m.id)
? prev.filter((id) => id !== m.id)
: [...prev, m.id],
)
}}
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
/>
<span
style={{
fontSize: 13,
color: "var(--text-primary)",
flex: 1,
}}
>
{m.name}
</span>
<span
style={{
fontSize: 11,
color: "var(--text-tertiary, #94a3b8)",
}}
>
{m.mime_type.split("/")[1].toUpperCase()}
</span>
</label>
)
})}
</div>
)}
</div>
</>
)}
{/* ── 自动匹配模式 ── */}
{materialMode === "auto" && (
<div className="xx-smart-match-section">
{/* 描述输入区 */}
<div className="xx-smart-match-input-area">
<label className="xx-smart-match-label">🤖 描述你想要的视频内容</label>
<textarea
className="xx-smart-match-input"
placeholder="例如:一个科技感十足的产品宣传视频,画面要有现代办公场景、团队协作、数据分析图表…"
value={smartMatchInput}
onChange={(e) => setSmartMatchInput(e.target.value)}
rows={3}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
handleSmartMatch()
}
}}
/>
<div className="xx-smart-match-input-footer">
<span className="xx-smart-match-tip">
{materialsLoading
? "扫描视频库中…"
: `当前视频库共 ${materials.items.length} 个素材可供匹配`}
</span>
<div style={{ display: "flex", gap: 8 }}>
{hasMatched && (
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={handleRefreshMatch}
disabled={smartMatching || materialsLoading}
>
🔄 换一批
</button>
)}
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={handleSmartMatch}
disabled={smartMatching || materialsLoading || !smartMatchInput.trim()}
>
{smartMatching ? (
<>
<LoadingOutlined style={{ marginRight: 6 }} />
匹配中…
</>
) : (
"✨ 智能匹配"
)}
</button>
</div>
</div>
</div>
{/* 推荐结果区 */}
{hasMatched && !smartMatching && smartMatchedResults.length > 0 && (
<div className="xx-smart-match-results">
<div className="xx-smart-match-results-header">
<span className="xx-smart-match-results-title">
推荐素材 ({smartMatchedResults.length}个)
</span>
<div className="xx-smart-match-results-actions">
<button type="button" className="xx-link-btn" onClick={handleSelectAllMatched}>
全选
</button>
<span style={{ color: "var(--border-color)" }}>|</span>
<button type="button" className="xx-link-btn" onClick={handleClearSmartSelect}>
清空
</button>
</div>
</div>
<div className="xx-smart-match-grid">
{smartMatchedResults.map((result) => {
const isSelected = smartSelectedIds.includes(result.asset.id)
return (
<div
key={result.asset.id}
className={`xx-smart-match-card ${isSelected ? "selected" : ""}`}
onClick={() => handleToggleSmartSelect(result.asset.id)}
>
{/* 缩略图 */}
<div className="xx-smart-match-thumb">
{result.asset.thumbnail_url ? (
<img src={result.asset.thumbnail_url} alt={result.asset.name} />
) : (
<div className="xx-smart-match-thumb-placeholder">
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
</div>
)}
<div className="xx-smart-match-score">{result.matchScore}%</div>
{isSelected && (
<div className="xx-smart-match-check">
<CheckCircleFilled style={{ fontSize: 20, color: "#fff" }} />
</div>
)}
{result.asset.duration && (
<div className="xx-smart-match-duration">
{formatDuration(result.asset.duration)}
</div>
)}
</div>
{/* 信息区 */}
<div className="xx-smart-match-info">
<div className="xx-smart-match-name" title={result.asset.name}>
{result.asset.name}
</div>
<div className="xx-smart-match-reason">🎯 {result.matchReason}</div>
</div>
</div>
)
})}
</div>
</div>
)}
{/* 匹配中状态 */}
{smartMatching && (
<div className="xx-smart-match-loading">
<LoadingOutlined
style={{ fontSize: 32, color: "var(--primary-color)", marginBottom: 12 }}
/>
<div style={{ color: "var(--text-primary)", fontSize: 14 }}>AI 正在分析素材…</div>
<div style={{ color: "var(--text-tertiary)", fontSize: 12, marginTop: 4 }}>
正在根据描述从视频库中匹配最合适的素材
</div>
</div>
)}
{/* 未匹配状态提示 */}
{!hasMatched && !smartMatching && (
<div className="xx-smart-match-empty">
<div style={{ fontSize: 36, marginBottom: 8 }}>💡</div>
<div style={{ color: "var(--text-secondary)", fontSize: 13 }}>
输入视频内容描述,点击「智能匹配」让 AI 帮你选素材
</div>
</div>
)}
{/* 已选素材汇总 */}
{hasMatched && !smartMatching && smartSelectedIds.length > 0 && (
<div className="xx-smart-match-summary">
<div className="xx-smart-match-summary-header">
<span className="xx-pill xx-pill-ok">已选 {smartSelectedIds.length} 个素材</span>
<span style={{ color: "var(--text-tertiary)", fontSize: 12 }}>
预计总时长约{" "}
{smartMatchedResults
.filter((r) => smartSelectedIds.includes(r.asset.id))
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
.toFixed(0)}{" "}
秒
</span>
</div>
</div>
)}
</div>
)}
</div>
)
/** 步骤 3:生成预览 */
const renderStep3 = () => (
<div className="xx-form-section">
<h3>🎬 生成预览</h3>
<div className="xx-preview-tip">
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
<span>素材已选好,AI 将为您智能匹配剪辑方案</span>
</div>
<div className="xx-preview-plan-card">
<div className="xx-preview-plan-title">模板草稿预览</div>
<div className="xx-preview-plan-info">
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">模板</span>
<span className="xx-preview-plan-value">{getTemplateName()}</span>
</div>
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">素材数量</span>
<span className="xx-preview-plan-value">
{materialMode === "auto"
? `${smartSelectedIds.length} 个素材(智能匹配)`
: `${selectedMaterials.length} 个素材`}
</span>
</div>
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">预计时长</span>
<span className="xx-preview-plan-value">{duration} 秒</span>
</div>
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label">视频比例</span>
<span className="xx-preview-plan-value">{videoRatio}</span>
</div>
</div>
<div className="xx-preview-plan-hint">
💡 点击「下一步」进入标题设置,AI 将根据素材内容为您推荐标题
</div>
</div>
</div>
)
/** 步骤 4:选择标题 + 标题样式设置 */
const renderStep4 = () => (
<div className="xx-form-section">
<h3>📝 选择标题</h3>
{/* AI 智能生成标题 */}
<div className="xx-ai-title-section">
<div className="xx-ai-title-header">
<span className="xx-ai-title-label">✨ AI 智能生成标题</span>
</div>
<div className="xx-ai-title-input-row">
<input
className="xx-ai-title-input"
placeholder="输入视频内容描述或关键词,如:职场成长、副业赚钱…"
value={aiTitleInput}
onChange={(e) => setAiTitleInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleGenerateAiTitles()
}}
/>
<button
type="button"
className="xx-btn xx-btn-primary"
onClick={handleGenerateAiTitles}
disabled={aiTitleGenerating || !aiTitleInput.trim()}
>
{aiTitleGenerating ? (
<>
<LoadingOutlined style={{ marginRight: 6 }} />
生成中
</>
) : (
"生成标题"
)}
</button>
</div>
{/* 生成结果 */}
{hasGeneratedTitles && !aiTitleGenerating && aiTitleResults.length > 0 && (
<div className="xx-ai-title-results">
<div className="xx-ai-title-results-header">
<span className="xx-ai-title-results-count">
为你生成 {aiTitleResults.length} 个标题
</span>
<button
type="button"
className="xx-link-btn"
onClick={handleRefreshAiTitles}
disabled={aiTitleGenerating}
>
🔄 换一批
</button>
</div>
<div className="xx-ai-title-list">
{aiTitleResults.map((item, idx) => {
const isSelected = titleSettings.title === item.title
return (
<div
key={idx}
className={`xx-ai-title-card ${isSelected ? "selected" : ""} ${item.style}`}
onClick={() => handleSelectAiTitle(item.title)}
>
<div className="xx-ai-title-card-text">{item.title}</div>
<div className="xx-ai-title-card-tag">{item.highlight}</div>
{isSelected && (
<div className="xx-ai-title-card-check">
<CheckCircleFilled style={{ color: "#fff", fontSize: 14 }} />
</div>
)}
</div>
)
})}
</div>
</div>
)}
{/* 生成中 */}
{aiTitleGenerating && (
<div className="xx-ai-title-loading">
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
AI 正在为你创作标题…
</div>
)}
</div>
<div className="xx-divider">
<span>或手动选择</span>
</div>
{/* AI 自动选择开关 */}
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI 自动选择标题</span>
<div
className={`xx-switch ${titleSettings.aiAutoSelect ? "active" : ""}`}
onClick={() =>
setTitleSettings((prev) => ({ ...prev, aiAutoSelect: !prev.aiAutoSelect }))
}
>
<div className="xx-switch-knob" />
</div>
</div>
{!titleSettings.aiAutoSelect && (
<>
{/* 标题内容选择 */}
<div className="xx-form-field">
<label>从标题库选择</label>
<Select
placeholder="请选择标题…"
allowClear
showSearch
style={{ width: "100%" }}
value={titleSettings.title || undefined}
onChange={(val) => setTitleSettings((prev) => ({ ...prev, title: val || "" }))}
options={userTitles.map((t) => ({
label: t.content,
value: t.content,
}))}
filterOption={(input, option) =>
((option?.label as string) || "").toLowerCase().includes(input.toLowerCase())
}
notFoundContent={
userTitles.length === 0 ? (
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
标题库为空,请前往「标题管理」添加
</span>
) : null
}
/>
</div>
<div className="xx-form-field" style={{ marginTop: 14 }}>
<label>或手动输入</label>
<input
placeholder="输入自定义标题…"
value={titleSettings.title}
onChange={(e) => setTitleSettings((prev) => ({ ...prev, title: e.target.value }))}
maxLength={50}
/>
</div>
{/* 标题样式设置区 */}
<div className="xx-title-style-section">
<h4 className="xx-section-subtitle">标题样式</h4>
{/* 位置 + 字体 一行 */}
<div className="xx-title-style-row">
<div className="xx-form-field xx-half-field">
<label>位置</label>
<select
className="xx-form-select"
value={titleSettings.position}
onChange={(e) =>
setTitleSettings((prev) => ({ ...prev, position: e.target.value }))
}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="xx-form-field xx-half-field">
<label>字体</label>
<select
className="xx-form-select"
value={titleSettings.font}
onChange={(e) => setTitleSettings((prev) => ({ ...prev, font: e.target.value }))}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
</div>
{/* 字号滑块 */}
<div className="xx-form-field">
<div className="xx-field-label-row">
<label>字号</label>
<span className="xx-field-value">{titleSettings.size}px</span>
</div>
<input
className="xx-slider"
type="range"
min={12}
max={48}
value={titleSettings.size}
onChange={(e) =>
setTitleSettings((prev) => ({
...prev,
size: Number(e.target.value),
}))
}
/>
</div>
{/* 预设样式 */}
<div className="xx-form-field">
<label>预设样式</label>
<div className="xx-title-presets-grid">
{TITLE_PRESETS.map((p) => {
const isActive = getActivePreset(titleSettings) === p.key
return (
<button
key={p.key}
className={`xx-title-preset-card${isActive ? " active" : ""}`}
onClick={() =>
setTitleSettings((prev) => ({
...prev,
size: p.style.size,
color: p.style.color,
bold: p.style.bold,
italic: p.style.italic,
stroke: p.style.stroke,
shadow: p.style.shadow,
}))
}
title={p.label}
>
<span
className="xx-title-preset-preview-text"
style={p.previewStyle as React.CSSProperties}
>
标题
</span>
<span className="xx-title-preset-card-label">{p.label}</span>
</button>
)
})}
</div>
</div>
{/* 样式按钮:粗体/斜体/描边/阴影 */}
<div className="xx-form-field">
<label>样式</label>
<div className="xx-style-btns">
<button
className={`xx-style-btn ${titleSettings.bold ? "active" : ""}`}
onClick={() => setTitleSettings((prev) => ({ ...prev, bold: !prev.bold }))}
title="粗体"
>
<b>B</b>
</button>
<button
className={`xx-style-btn ${titleSettings.italic ? "active" : ""}`}
onClick={() => setTitleSettings((prev) => ({ ...prev, italic: !prev.italic }))}
title="斜体"
>
<i>I</i>
</button>
<button
className={`xx-style-btn ${titleSettings.stroke ? "active" : ""}`}
onClick={() => setTitleSettings((prev) => ({ ...prev, stroke: !prev.stroke }))}
title="描边"
>
S
</button>
<button
className={`xx-style-btn ${titleSettings.shadow ? "active" : ""}`}
onClick={() => setTitleSettings((prev) => ({ ...prev, shadow: !prev.shadow }))}
title="阴影"
>
☁
</button>
</div>
</div>
</div>
</>
)}
</div>
)
/** 步骤 4:选择配音 */
const renderStep5 = () => (
<div className="xx-form-section">
<h3>🎙️ 选择配音</h3>
{/* AI 智能推荐配音 */}
<div className="xx-voice-recommend-section">
<div className="xx-voice-recommend-header">
<span className="xx-voice-recommend-label">✨ AI 智能推荐</span>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={handleVoiceRecommend}
disabled={voiceRecommendLoading || presetVoicesLoading}
>
{voiceRecommendLoading ? (
<>
<LoadingOutlined style={{ marginRight: 6 }} />
推荐中
</>
) : hasVoiceRecommend ? (
"换一批"
) : (
"智能推荐"
)}
</button>
</div>
{voiceRecommendLoading && (
<div className="xx-voice-recommend-loading">
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
根据视频内容为你匹配最合适的音色…
</div>
)}
{!voiceRecommendLoading && hasVoiceRecommend && voiceRecommendations.length > 0 && (
<div className="xx-voice-recommend-list">
{voiceRecommendations.map((voiceId) => {
const v = presetVoices.find((pv) => pv.voice_id === voiceId)
if (!v) return null
const isSelected = voiceMode === "preset" && selectedVoice === v.voice_id
return (
<div
key={v.voice_id}
className={`xx-voice-recommend-card ${isSelected ? "selected" : ""}`}
onClick={() => handleSelectRecommendedVoice(v.voice_id)}
>
<div className="xx-voice-recommend-avatar">
{VOICE_GENDER_ICON[v.gender] ?? "✨"}
</div>
<div className="xx-voice-recommend-info">
<div className="xx-voice-recommend-name">{v.name}</div>
<div className="xx-voice-recommend-desc">{v.description}</div>
</div>
{isSelected && (
<div className="xx-voice-recommend-check">
<CheckCircleFilled style={{ color: "#fff", fontSize: 16 }} />
</div>
)}
</div>
)
})}
</div>
)}
{!voiceRecommendLoading && !hasVoiceRecommend && (
<div className="xx-voice-recommend-empty">
<span>点击「智能推荐」,AI 根据视频内容匹配音色</span>
</div>
)}
</div>
<div className="xx-divider">
<span>全部音色</span>
</div>
{/* 配音方式选择卡片 — 从 API 预设音色动态生成 */}
<div className="xx-voice-choice-list" style={{ marginBottom: 16 }}>
{presetVoices.slice(0, 3).map((v) => (
<div
key={v.voice_id}
className={`xx-voice-choice-item ${voiceMode === "preset" && selectedVoice === v.voice_id ? "selected" : ""}`}
onClick={() => {
setVoiceMode("preset")
setSelectedVoice(v.voice_id)
}}
role="button"
tabIndex={0}
aria-pressed={voiceMode === "preset" && selectedVoice === v.voice_id}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setVoiceMode("preset")
setSelectedVoice(v.voice_id)
}
}}
>
<span className="xx-voice-choice-check">✓</span>
<div className="xx-voice-choice-avatar">{VOICE_GENDER_ICON[v.gender] ?? "✨"}</div>
<div className="xx-voice-choice-info">
<h4>{v.name}</h4>
<p>{v.description}</p>
</div>
</div>
))}
{presetVoicesLoading && (
<div className="xx-voice-choice-item" style={{ opacity: 0.5 }}>
<div className="xx-voice-choice-avatar">⏳</div>
<div className="xx-voice-choice-info">
<h4>加载中…</h4>
</div>
</div>
)}
{/* 克隆我的声音 */}
<div
className={`xx-voice-choice-item ${voiceMode === "clone" ? "selected" : ""}`}
onClick={() => setVoiceMode("clone")}
role="button"
tabIndex={0}
aria-pressed={voiceMode === "clone"}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setVoiceMode("clone")
}
}}
>
<span className="xx-voice-choice-check">✓</span>
<div
className="xx-voice-choice-avatar"
style={{ background: "linear-gradient(135deg, #10b981, #059669)" }}
>
🎤
</div>
<div className="xx-voice-choice-info">
<h4>克隆我的声音</h4>
<p>上传语音样本克隆</p>
</div>
</div>
</div>
{/* 预设音色详细列表(当 voiceMode === preset 时显示 API 返回的音色) */}
{voiceMode === "preset" && (
<div>
<div className="xx-form-field">
<label>从配音库选择</label>
<select value={selectedVoice} onChange={(e) => setSelectedVoice(e.target.value)}>
<option value="">请选择配音…</option>
{presetVoicesLoading ? (
<option disabled>加载中…</option>
) : (
presetVoices.map((v) => (
<option key={v.voice_id} value={v.voice_id}>
{v.name} — {v.description}
</option>
))
)}
</select>
</div>
{/* 试听按钮 */}
{presetVoices.length > 0 && (
<div
style={{
display: "flex",
gap: 8,
flexWrap: "wrap",
marginTop: 8,
}}
>
{presetVoices.slice(0, 4).map((v) => (
<button
key={v.voice_id}
className="xx-btn xx-btn-ghost"
style={{ height: 32, padding: "0 12px", fontSize: 12 }}
onClick={() => toggleVoicePlay(v.voice_id, v.preview_url)}
>
{playingVoice === v.voice_id ? (
<>
<PauseCircleOutlined /> 停止
</>
) : (
<>
<PlayCircleOutlined /> {v.name}
</>
)}
</button>
))}
</div>
)}
</div>
)}
{/* 自定义录制 */}
{voiceMode === "custom" && (
<div>
<textarea
placeholder="输入配音文案,点击合成按钮生成语音…"
value={customVoiceText}
onChange={(e) => setCustomVoiceText(e.target.value)}
maxLength={500}
style={{
width: "100%",
minHeight: 100,
border: "1px solid var(--border-color, #e2e8f0)",
borderRadius: "var(--radius-sm, 10px)",
padding: 12,
fontSize: 14,
resize: "vertical",
outline: "none",
}}
/>
<div style={{ marginTop: 12, display: "flex", gap: 8 }}>
<button
className="xx-btn xx-btn-ghost"
disabled={!customVoiceText.trim() || synthesizeMutation.isPending}
onClick={handleSynthesizeVoice}
>
<AudioOutlined /> {synthesizeMutation.isPending ? "合成中…" : "合成语音"}
</button>
</div>
{ttsError && (
<Text
style={{
color: "var(--error, #ef4444)",
marginTop: 8,
display: "block",
}}
>
{ttsError}
</Text>
)}
{customAudioUrl && completedTtsJobId && (
<div
style={{
marginTop: 8,
display: "flex",
alignItems: "center",
gap: 12,
}}
>
<Text style={{ color: "var(--success, #10b981)" }}>✓ 语音合成完成</Text>
<button
className="xx-btn xx-btn-primary"
style={{ height: 30, padding: "0 14px", fontSize: 12 }}
onClick={handleOpenSaveModal}
>
<SaveOutlined /> 存为素材
</button>
</div>
)}
{/* ── 存为素材弹窗 ── */}
{saveModalOpen && (
<div className="xx-save-modal-overlay" onClick={() => setSaveModalOpen(false)}>
<div className="xx-save-modal" onClick={(e) => e.stopPropagation()}>
<div className="xx-save-modal-header">
<span>保存到配音库</span>
<button className="xx-save-modal-close" onClick={() => setSaveModalOpen(false)}>
<CloseOutlined />
</button>
</div>
<div className="xx-save-modal-body">
<label className="xx-save-modal-label">素材名称</label>
<input
className="xx-save-modal-input"
placeholder="留空则自动生成名称"
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
maxLength={50}
/>
<label className="xx-save-modal-label">
标签
<span
style={{
fontWeight: 400,
color: "var(--text-tertiary, #94a3b8)",
}}
>
(可选)
</span>
</label>
<div className="xx-save-modal-tags">
{saveTagIds.map((id) => {
const tag = allTags.find((t) => t.id === id)
return tag ? (
<span key={id} className="xx-save-modal-tag active">
{tag.name}
<CloseOutlined
className="xx-save-modal-tag-remove"
onClick={() => setSaveTagIds((prev) => prev.filter((x) => x !== id))}
/>
</span>
) : null
})}
<input
className="xx-save-modal-tag-input"
placeholder="输入标签名回车添加"
value={saveNewTag}
onChange={(e) => setSaveNewTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleAddTagInModal(saveNewTag)
}
}}
/>
</div>
{allTags.length > 0 && (
<div className="xx-save-modal-tag-presets">
{allTags
.filter((t) => !saveTagIds.includes(t.id))
.slice(0, 12)
.map((t) => (
<button
key={t.id}
className="xx-save-modal-tag-preset"
onClick={() => setSaveTagIds((prev) => [...prev, t.id])}
>
{t.name}
<PlusOutlined style={{ fontSize: 10, marginLeft: 4 }} />
</button>
))}
</div>
)}
</div>
<div className="xx-save-modal-footer">
<button className="xx-btn xx-btn-ghost" onClick={() => setSaveModalOpen(false)}>
取消
</button>
<button
className="xx-btn xx-btn-primary"
disabled={saveToLibraryMutation.isPending}
onClick={handleConfirmSave}
>
{saveToLibraryMutation.isPending ? "保存中…" : "保存"}
</button>
</div>
</div>
</div>
)}
</div>
)}
{/* 克隆声音展开区域 */}
{voiceMode === "clone" && (
<div className="xx-clone-section">
<p className="xx-clone-section-title">克隆我的声音</p>
<Text style={{ fontSize: 12, color: "var(--text-tertiary, #94a3b8)" }}>
上传一段您的语音样本,AI 将克隆您的声音用于视频配音
</Text>
<div style={{ marginTop: 12 }}>
<button
className="xx-btn xx-btn-primary"
style={{ height: 36, fontSize: 13 }}
onClick={() => setCloneModalOpen(true)}
>
<ThunderboltOutlined /> 克隆新声音
</button>
</div>
{/* 轮询提示 */}
{hasProcessing && (
<div className="xx-clone-polling-hint" style={{ marginTop: 10 }}>
<span className="xx-clone-polling-dot" />
正在同步克隆进度...
</div>
)}
{/* 已克隆声音列表 */}
{clonedVoices.length > 0 && (
<div className="xx-clone-voices-list">
{clonedVoices.map((cv) => {
const statusCfg = CLONE_STATUS_CONFIG[cv.status]
const isReady = cv.status === "ready"
const selected = selectedClonedVoice === cv.id
return (
<div
key={cv.id}
className={`xx-clone-voice-row ${selected ? "selected" : ""} ${!isReady ? "disabled" : ""}`}
onClick={() => {
if (isReady) setSelectedClonedVoice(cv.id)
}}
role="button"
tabIndex={isReady ? 0 : -1}
aria-pressed={selected}
>
<div className={`xx-clone-avatar ${cv.status}`}>
<AudioOutlined />
</div>
<div className="xx-clone-info">
<div className="xx-clone-name">{cv.name}</div>
<div className="xx-clone-status">
<span
className="xx-clone-status-dot"
style={{ background: statusCfg.color }}
/>
<span style={{ color: statusCfg.color }}>{statusCfg.label}</span>
{isReady && (
<span
style={{
color: "var(--text-secondary)",
marginLeft: 8,
}}
>
{formatDuration(cv.duration_seconds)}
</span>
)}
</div>
</div>
{selected && isReady && (
<CheckCircleFilled style={{ color: "var(--primary-color, #4f46e5)" }} />
)}
</div>
)
})}
</div>
)}
{clonedVoices.length === 0 && (
<Text
style={{
color: "var(--text-secondary)",
display: "block",
textAlign: "center",
padding: "16px 0",
}}
>
暂无克隆音色,点击「克隆新声音」开始
</Text>
)}
<Text
style={{
fontSize: 12,
color: "var(--text-tertiary, #94a3b8)",
marginTop: 10,
display: "block",
}}
>
💡 提示:录音环境越安静,克隆效果越好。
</Text>
</div>
)}
</div>
)
/** 步骤 5:选择封面 */
const renderStep6 = () => {
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
const ms = Math.floor((seconds % 1) * 10)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
}
const totalDuration = duration || 30
return (
<div className="xx-form-section">
<h3>🖼️ 选择封面</h3>
{/* 启用开关 */}
<div className="xx-cover-header">
<span className="xx-cover-header-label">启用自定义封面</span>
<label className="xx-switch">
<input
type="checkbox"
checked={coverSettings.enabled}
onChange={(e) => setCoverSettings((prev) => ({ ...prev, enabled: e.target.checked }))}
/>
<span className="xx-switch-slider" />
</label>
</div>
{coverSettings.enabled && (
<>
{/* 模式选择 */}
<div className="xx-section-title">封面来源</div>
<div className="xx-cover-mode-tabs">
{(["auto", "frame", "upload"] as const).map((m) => (
<button
key={m}
className={`xx-cover-mode-tab${coverSettings.mode === m ? " active" : ""}`}
onClick={() => setCoverSettings((prev) => ({ ...prev, mode: m }))}
>
<span className="xx-cover-mode-icon">{COVER_MODE_ICONS[m]}</span>
<span className="xx-cover-mode-label">{COVER_MODE_LABELS[m]}</span>
</button>
))}
</div>
{/* 智能封面 */}
{coverSettings.mode === "auto" && (
<div className="xx-cover-auto">
<div className="xx-cover-auto-desc">
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
</div>
<div className="xx-cover-auto-badge">
<span style={{ fontSize: 24 }}>🤖</span>
<span>AI 智能选帧</span>
</div>
</div>
)}
{/* 抽帧选封面 */}
{coverSettings.mode === "frame" && (
<div className="xx-cover-frame">
<div className="xx-cover-frame-preview">
<div className="xx-cover-frame-placeholder">
<span className="xx-cover-frame-icon">🎞️</span>
<span className="xx-cover-frame-time">
{formatTime(coverSettings.frame_time)}
</span>
</div>
</div>
<div className="xx-cover-frame-slider">
<div className="xx-cover-frame-slider-header">
<span>拖动选择封面帧</span>
<span className="xx-cover-frame-value">
{formatTime(coverSettings.frame_time)}
</span>
</div>
<input
type="range"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={coverSettings.frame_time}
onChange={(e) =>
setCoverSettings((prev) => ({
...prev,
frame_time: Number(e.target.value),
}))
}
className="xx-cover-range"
/>
<div className="xx-cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
<div className="xx-cover-frame-quick">
<span className="xx-cover-quick-label">快捷选帧:</span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio
return (
<button
key={ratio}
className="xx-cover-quick-btn"
onClick={() => setCoverSettings((prev) => ({ ...prev, frame_time: t }))}
>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)}
{/* 上传封面 */}
{coverSettings.mode === "upload" && (
<div className="xx-cover-upload">
<div
className="xx-cover-upload-area"
onClick={() => {
const input = document.getElementById("cover-upload-input")
input?.click()
}}
>
{coverSettings.upload_url ? (
<div className="xx-cover-upload-preview">
<img src={coverSettings.upload_url} alt="封面预览" />
<div className="xx-cover-upload-overlay">点击更换</div>
</div>
) : (
<div className="xx-cover-upload-placeholder">
<span style={{ fontSize: 32 }}>📤</span>
<span className="xx-cover-upload-text">点击上传封面图片</span>
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
</div>
)}
<input
id="cover-upload-input"
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = (ev) => {
const url = ev.target?.result as string
setCoverSettings((prev) => ({
...prev,
upload_url: url,
thumbnail_url: url,
mode: "upload",
}))
}
reader.readAsDataURL(file)
}
}}
/>
</div>
</div>
)}
{/* 封面预览 */}
<div className="xx-section-title">封面预览</div>
<div className="xx-cover-preview-box">
{coverSettings.upload_url ? (
<img
src={coverSettings.upload_url}
alt="封面预览"
className="xx-cover-preview-img"
/>
) : (
<div className="xx-cover-preview-placeholder">
<span style={{ fontSize: 28 }}>🖼️</span>
<span>
{coverSettings.mode === "auto"
? "AI 智能选择"
: coverSettings.mode === "frame"
? `帧 ${formatTime(coverSettings.frame_time)}`
: "未上传封面"}
</span>
</div>
)}
<div className="xx-cover-preview-ratio">16:9</div>
</div>
</>
)}
</div>
)
}
/** 步骤 6:确认生成 */
const renderStep7 = () => (
<div className="xx-form-section">
<h3>✨ 确认生成</h3>
<div className="xx-summary-card">
<div className="xx-summary-row">
<span className="xx-summary-label">模板</span>
<span className="xx-summary-value">{getTemplateName()}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">素材</span>
<span className="xx-summary-value">
{materialMode === "auto"
? `${smartSelectedIds.length} 个素材(智能匹配)`
: `${selectedMaterials.length} 个素材`}
</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">标题</span>
<span className="xx-summary-value">{titleSettings.title || "未选择"}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">配音</span>
<span className="xx-summary-value">{getVoiceName()}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">封面</span>
<span className="xx-summary-value">
{coverSettings.enabled ? COVER_MODE_LABELS[coverSettings.mode] || "智能封面" : "不使用"}
</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">生成数量</span>
<span className="xx-summary-value">
<div className="xx-count-stepper">
<button
className="xx-count-stepper-btn"
disabled={generateCount <= 1 || generating}
onClick={() => setGenerateCount((c) => Math.max(1, c - 1))}
>
<MinusOutlined />
</button>
<span className="xx-count-stepper-value">{generateCount}</span>
<button
className="xx-count-stepper-btn"
disabled={generateCount >= 10 || generating}
onClick={() => setGenerateCount((c) => Math.min(10, c + 1))}
>
<PlusOutlined />
</button>
<span className="xx-count-stepper-hint">条视频</span>
</div>
</span>
</div>
</div>
{/* 生成进度 / 结果反馈 */}
{(generating || generated || generateError) && (
<div style={{ marginTop: 16 }}>
{generating && (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-icon">
<LoadingOutlined />
</div>
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase">
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
</div>
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
</div>
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
</div>
<div className="xx-gen-progress-bar">
<div
className="xx-gen-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<div className="xx-gen-progress-tip">
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
</div>
</div>
)}
{generated && !generating && (
<div className="xx-gen-success-card">
<div className="xx-gen-success-icon">
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
</div>
<div className="xx-gen-success-info">
<div className="xx-gen-success-title">视频生成完成!</div>
<div className="xx-gen-success-sub">
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
</div>
</div>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={() => {
// 滚动到右侧预览区
const el = document.querySelector(".xx-preview-section")
el?.scrollIntoView({ behavior: "smooth", block: "start" })
}}
>
查看结果
</button>
</div>
)}
{generateError && !generating && (
<div className="xx-gen-error-card">
<div className="xx-gen-error-icon">
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
</div>
<div className="xx-gen-error-info">
<div className="xx-gen-error-title">生成失败</div>
<div className="xx-gen-error-msg">
{typeof generateError === "string"
? generateError
: JSON.stringify(generateError)}
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={handleRetryGenerate}
>
🔄 重试
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={() => setGenerateError(null)}
>
知道了
</button>
</div>
</div>
)}
</div>
)}
</div>
)
/** 渲染当前步骤 */
const renderCurrentStep = () => {
switch (currentStep) {
case 1:
return renderStep1()
case 2:
return renderStep2()
case 3:
return renderStep3()
case 4:
return renderStep4()
case 5:
return renderStep5()
case 6:
return renderStep6()
case 7:
return renderStep7()
default:
return null
}
}
/* ================================================================
渲染 — 主页面
================================================================ */
return (
<div className="xx-generate-page">
{/* ── 页头 ── */}
<GenerateHeader fromEditPlan={!!editPlanId} />
{/* ── 步骤条 ── */}
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
{/* ── 主布局 ── */}
<div className="xx-generate-layout">
{/* ════ 左侧:表单区 ════ */}
<div className="xx-generate-form">
{/* 当前步骤 */}
{renderCurrentStep()}
{/* 底部操作按钮 */}
<div className="xx-step-actions">
<button className="xx-btn xx-btn-ghost" onClick={goPrev} disabled={currentStep === 1}>
← 上一步
</button>
{currentStep < 7 ? (
<button className="xx-btn xx-btn-primary" onClick={goNext}>
下一步 →
</button>
) : (
<button
className="xx-btn xx-btn-primary"
onClick={handleGenerate}
disabled={generating || (generated && !generateError)}
>
<ThunderboltOutlined />
{generating
? "生成中…"
: generated && !generateError
? "已生成"
: generateError
? "🔄 重新生成"
: "✨ 确认生成"}
</button>
)}
</div>
</div>
{/* ════ 右侧:生成结果 ════ */}
<GenerateResultPanel
generated={generated}
generating={generating}
progress={progress}
generateError={generateError}
generatedVideos={generatedVideos}
onVideoPreview={(video) => {
setPreviewVideo(video)
setPreviewModalOpen(true)
}}
onDownload={handleDownload}
onShare={handleShare}
onGoToLibrary={() => navigate("/app/products")}
/>
</div>
{/* ── 视频预览弹窗 ── */}
<Modal
open={previewModalOpen}
onCancel={() => setPreviewModalOpen(false)}
footer={null}
width="80vw"
centered
destroyOnClose
>
{previewVideo && (
<div className="xx-preview-modal-content">
<video
src={previewVideo.download_url || previewVideo.file_url}
controls
autoPlay
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain" }}
poster={previewVideo.thumbnail_url || undefined}
/>
</div>
)}
</Modal>
{/* ── 音色克隆弹窗 ── */}
<CloneModal
open={cloneModalOpen}
onClose={() => setCloneModalOpen(false)}
onSuccess={handleCloneSuccess}
/>
</div>
)
}
export default GeneratePage