Files
xiaoxia-saas/apps/web/src/pages/generate/GeneratePage.tsx
T
xiaoxia 7e62d886de
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 31s
AI Code Review / AI Code Review (pull_request) Failing after 32s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m40s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 2m18s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 2m23s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 9m37s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1415h22m27s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1415h24m4s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1415h24m34s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1415h31m33s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1415h31m34s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1415h31m34s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1415h31m35s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1415h31m36s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1415h31m36s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1415h31m36s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1415h31m38s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1416h3m43s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1416h3m40s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1416h3m43s
feat(P1): #580 智能剪辑流程重构 - 新增生成预览步骤
- 流程调整为7步:选择模板→选择素材→生成预览→选择标题→选择配音→选择封面→确认生成
- 新增「生成预览」步骤,展示剪辑计划概览(模板、素材、时长、比例)
- 所有步骤编号顺延调整
2026-07-19 15:46:33 +08:00

2375 lines
83 KiB
TypeScript
Executable File

/**
* 智能剪辑页面 — V21 原型 1:1 还原
* 5 步向导:选择模板 → 选择素材 → 选择标题 → 选择配音 → 确认生成
* 左右布局:左侧 generate-form + 右侧 generate-preview
* 保留所有现有业务逻辑(API 调用、URL 参数、CloneModal、TTS 等)
*/
import React, { useState, useRef, useCallback, useEffect } from "react"
import { useQuery, useMutation } from "@tanstack/react-query"
import { Typography, message, Select } from "antd"
import {
AudioOutlined,
ThunderboltOutlined,
CheckCircleFilled,
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
DownloadOutlined,
ShareAltOutlined,
SaveOutlined,
PlusOutlined,
MinusOutlined,
CloseOutlined,
} from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import { getAssets, getAssetLibraries } from "@/api/assets"
import {
createEditPlan,
generateEditPlan,
updateEditPlan,
getGenerationTaskResults,
} from "@/api/editPlans"
import type { GeneratedVideo, EditPlanConfig, TitleConfig } from "@/api/editPlans"
import type { CoverConfig } from "../editing-planner/types"
import { getEditingTemplates } from "@/api/editingPlanner"
import { getTitles } from "@/api/titles"
import apiClient from "@/api/client"
import { fetchPresetVoices } from "@/api/voices"
import type { PresetVoiceItem } from "@/api/voices"
import { formatDuration } from "@/api/voiceClone"
import type { VoiceClone } from "@/api/voiceClone"
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 { getEditPlan } from "@/api/editPlans"
import "./generate.css"
const { Text } = Typography
/* ── 克隆声音状态配置 ── */
const CLONE_STATUS_CONFIG: Record<string, { label: string; color: string }> = {
ready: { label: "就绪", color: "var(--secondary-color, #10b981)" },
processing: { label: "克隆中", color: "var(--accent-color, #f59e0b)" },
failed: { label: "失败", color: "var(--error-color, #ef4444)" },
}
/* ── 模板渐变色映射(根据 mode 分配视觉样式) ── */
const MODE_GRADIENTS: Record<string, string> = {
pip: "linear-gradient(135deg, #fbbf24, #f59e0b)",
one_take: "linear-gradient(135deg, #3b82f6, #1d4ed8)",
voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)",
voice_pip: "linear-gradient(135deg, #10b981, #059669)",
}
/* ── 配音预设卡片:从 API 动态生成,不再硬编码 ── */
const VOICE_GENDER_ICON: Record<string, string> = {
female: "🎀",
male: "🎙️",
child: "🧒",
neutral: "✨",
}
/* ── 步骤定义 ── */
const STEPS = [
{ key: 1, label: "选择模板" },
{ key: 2, label: "选择素材" },
{ key: 3, label: "生成预览" },
{ key: 4, label: "选择标题" },
{ key: 5, label: "选择配音" },
{ key: 6, label: "选择封面" },
{ key: 7, label: "确认生成" },
]
/* ── 标题设置常量 ── */
const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"]
interface TitleSettings {
aiAutoSelect: boolean
title: string
position: string
font: string
size: number
bold: boolean
italic: boolean
stroke: boolean
shadow: boolean
color: string
}
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "bottom",
font: "思源黑体",
size: 28,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
}
const TITLE_PRESETS = [
{
key: "classic_white",
label: "经典白字",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
fontWeight: 700,
color: "#ffffff",
WebkitTextStroke: "1px #000000",
fontSize: "20px",
},
},
{
key: "black_gold",
label: "黑金质感",
style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
fontWeight: 700,
color: "#d4a843",
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
fontSize: "20px",
},
},
{
key: "fresh_minimal",
label: "清新简约",
style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false },
previewStyle: { fontWeight: 400, color: "#333333", fontSize: "18px" },
},
{
key: "variety_show",
label: "综艺花字",
style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true },
previewStyle: {
fontWeight: 900,
color: "#ff4081",
WebkitTextStroke: "1.5px #ffffff",
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
fontSize: "22px",
},
},
{
key: "business",
label: "商务极简",
style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false },
previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" },
},
{
key: "retro_film",
label: "复古胶片",
style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true },
previewStyle: {
fontWeight: 400,
color: "#e8d5b7",
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
fontSize: "18px",
},
},
{
key: "neon_glow",
label: "霓虹发光",
style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
fontWeight: 700,
color: "#00e5ff",
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
fontSize: "20px",
},
},
{
key: "handwriting",
label: "手写字",
style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true },
previewStyle: {
fontWeight: 400,
color: "#333333",
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
fontSize: "20px",
},
},
]
/* ── 封面设置常量 ── */
const COVER_MODE_LABELS: Record<string, string> = {
auto: "智能封面",
frame: "抽帧选封面",
upload: "上传封面",
}
const COVER_MODE_ICONS: Record<string, string> = {
auto: "🤖",
frame: "🎞️",
upload: "📤",
}
const DEFAULT_COVER_SETTINGS: CoverConfig = {
enabled: true,
mode: "auto",
frame_time: 0,
upload_url: "",
ai_suggested_time: null,
thumbnail_url: "",
}
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 [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 [videoUrl, setVideoUrl] = useState<string>("")
const [thumbnailUrl, setThumbnailUrl] = useState<string>("")
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[] = presetVoicesData?.items ?? []
/* ── 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 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()
}
const plan = await createEditPlan({
template_id: selectedTemplate,
name: titleSettings.title.trim(),
config: {
asset_ids: 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.enabled
? {
enabled: true,
mode: coverSettings.mode,
frame_time: coverSettings.frame_time,
upload_url: coverSettings.upload_url,
ai_suggested_time: coverSettings.ai_suggested_time,
thumbnail_url: coverSettings.thumbnail_url,
}
: { enabled: false },
...voiceConfig,
ratio: videoRatio,
style,
duration,
auto_subtitles: autoSubtitles,
bgm,
generate_count: generateCount,
material_mode: materialMode,
},
total_duration: duration,
source_edit_plan_id: editPlanId || undefined,
})
// 后端要求计划处于 editing 状态才能触发渲染,自动转换状态
await updateEditPlan(plan.id, { status: "editing" })
await generateEditPlan(plan.id)
const poll = async () => {
try {
const status = await apiClient.get(`/edit-plans/${plan.id}/generation-status`)
const data = status.data
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)
if (videos.length > 0) {
setVideoUrl(videos[0].file_url || videos[0].download_url || "")
setThumbnailUrl(videos[0].thumbnail_url || "")
}
} catch (err) {
console.error("[获取生成结果失败]", err)
}
}
message.success("视频生成完成!")
return
}
if (data.plan_status === "failed") {
setGenerating(false)
// 提取后端返回的错误详情,便于排查
// 注意:后端返回的 error_message/error/message 可能是对象而非字符串
const rawMsg =
data.error_message ||
data.error ||
data.message ||
(data.clips || []).find((c: { status: string }) => 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("[生成失败] planId:", plan.id, "响应:", 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("[轮询出错] planId:", plan.id, 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,
editPlanId,
selectedTemplate,
generateCount,
materialMode,
])
/* ── 下载视频 ── */
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 === 4 && !titleSettings.title.trim()) {
message.warning("请选择或输入标题")
return
}
if (currentStep < 7) {
setCurrentStep((s) => s + 1)
}
}, [
currentStep,
selectedTemplate,
selectedMaterials.length,
titleSettings,
materialMode,
coverSettings,
])
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-auto-match-card">
<div className="xx-auto-match-icon">🤖</div>
<div className="xx-auto-match-body">
<h4 className="xx-auto-match-title">智能素材匹配</h4>
<p className="xx-auto-match-desc">
系统将根据所选模板和标题,从视频库中自动分析并匹配最合适的素材进行视频生成。
无需手动挑选,AI 会综合素材质量、时长、内容相关性等维度进行智能筛选。
</p>
<div className="xx-auto-match-features">
<span className="xx-auto-match-feature">📊 质量评分筛选</span>
<span className="xx-auto-match-feature">🎯 内容相关性匹配</span>
<span className="xx-auto-match-feature">⏱️ 时长智能分配</span>
</div>
</div>
{materialsLoading ? (
<Text
style={{
color: "var(--text-secondary)",
fontSize: 12,
marginTop: 8,
}}
>
扫描视频库中…
</Text>
) : (
<Text
style={{
color: "var(--text-tertiary, #94a3b8)",
fontSize: 12,
marginTop: 8,
}}
>
当前视频库共 {materials.items.length} 个素材可供匹配
</Text>
)}
</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" ? "AI自动匹配" : `${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-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>
{/* 配音方式选择卡片 — 从 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" ? "自动匹配" : `${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-progress-bar">
<div
className="xx-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
<LoadingOutlined style={{ marginRight: 6 }} />
正在生成视频,请稍候… {Math.round(progress)}%
</Text>
</>
)}
{generated && !generating && (
<div
style={{
padding: "12px 16px",
borderRadius: 8,
background: "rgba(82, 196, 26, 0.08)",
border: "1px solid rgba(82, 196, 26, 0.3)",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<CheckCircleOutlined style={{ color: "#52c41a", fontSize: 18 }} />
<div>
<Text strong style={{ color: "#52c41a", display: "block", fontSize: 14 }}>
视频生成完成!
</Text>
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
可在右侧预览或前往成片库查看
</Text>
</div>
</div>
)}
{generateError && !generating && (
<div
style={{
padding: "12px 16px",
borderRadius: 8,
background: "rgba(255, 77, 79, 0.08)",
border: "1px solid rgba(255, 77, 79, 0.3)",
display: "flex",
alignItems: "flex-start",
gap: 8,
}}
>
<CloseCircleOutlined
style={{
color: "#ff4d4f",
fontSize: 18,
marginTop: 2,
flexShrink: 0,
}}
/>
<div>
<Text strong style={{ color: "#ff4d4f", display: "block", fontSize: 14 }}>
生成失败
</Text>
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
{typeof generateError === "string"
? generateError
: JSON.stringify(generateError)}
</Text>
</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">
{/* ── 页头 ── */}
<div className="xx-generate-head">
<div>
<h2>
<ThunderboltOutlined style={{ marginRight: 8 }} />
智能剪辑
</h2>
<p>快速生成短视频,支持多种风格和素材组合</p>
</div>
{editPlanId && (
<span
style={{
background: "#dbeafe",
color: "#1d4ed8",
fontSize: 12,
padding: "4px 10px",
borderRadius: 999,
fontWeight: 600,
}}
>
🎬 来自剪辑计划
</span>
)}
</div>
{/* ── 步骤条 ── */}
<div className="xx-steps-bar">
{STEPS.map((step, idx) => {
const isActive = currentStep === step.key
const isDone = currentStep > step.key
const cls = ["xx-step-item", isActive ? "active" : "", isDone ? "done" : ""]
.filter(Boolean)
.join(" ")
return (
<React.Fragment key={step.key}>
{idx > 0 && <span className="xx-step-arrow">→</span>}
<div
className={cls}
onClick={() => {
// 允许点击已完成的步骤回退
if (isDone) setCurrentStep(step.key)
}}
role="button"
tabIndex={0}
>
<div className="xx-step-num">{isDone ? "✓" : step.key}</div>
<span className="xx-step-label">{step.label}</span>
</div>
</React.Fragment>
)
})}
</div>
{/* ── 主布局 ── */}
<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>
{/* ════ 右侧:预览区 ════ */}
<div className="xx-generate-preview">
{/* 视频预览 */}
<div className="xx-preview-video">
{generated && videoUrl ? (
<video
src={videoUrl}
controls
preload="metadata"
poster={thumbnailUrl || undefined}
style={{ width: "100%", height: "100%", objectFit: "contain" }}
/>
) : generated ? (
<div
style={{
textAlign: "center",
padding: 24,
color: "var(--text-secondary)",
}}
>
<LoadingOutlined style={{ fontSize: 24, marginBottom: 8 }} />
<div>视频处理中,请稍候…</div>
</div>
) : (
<button className="xx-play-btn" type="button">
<PlayCircleOutlined />
</button>
)}
</div>
{/* 字幕预览 */}
<div className="xx-preview-caption">
{titleSettings.title || "3秒抓住注意力,30秒讲清卖点"}
</div>
{/* 时间线标题 */}
<div className="xx-preview-title">剪辑计划预览</div>
{/* 时间线列表 */}
{generated ? (
<div className="xx-preview-timeline">
<div className="xx-timeline-item">
<span className="scene-name">生成完成,可下载或分享视频</span>
</div>
</div>
) : (
<div className="xx-preview-timeline">
<div className="xx-timeline-item">
<span className="scene-name">确认标题后自动生成剪辑计划</span>
</div>
</div>
)}
{/* 生成操作按钮 */}
<div className="xx-generate-actions">
<button className="xx-btn xx-btn-ghost" onClick={handleGenerate} disabled={generating}>
重新生成
</button>
<button
className="xx-btn xx-btn-primary"
onClick={handleGenerate}
disabled={generating}
>
<ThunderboltOutlined />
{generating ? "生成中…" : "✨ 确认生成"}
</button>
</div>
{/* 生成完成后显示下载/分享 */}
{generated && (
<div className="xx-generate-actions" style={{ marginTop: 8 }}>
<button className="xx-btn xx-btn-ghost" onClick={handleDownload}>
<DownloadOutlined /> 下载视频
</button>
<button className="xx-btn xx-btn-ghost" onClick={handleShare}>
<ShareAltOutlined /> 分享
</button>
<button className="xx-btn xx-btn-ghost" onClick={() => navigate("/app/products")}>
前往成片库 →
</button>
</div>
)}
</div>
</div>
{/* ── 音色克隆弹窗 ── */}
<CloneModal
open={cloneModalOpen}
onClose={() => setCloneModalOpen(false)}
onSuccess={handleCloneSuccess}
/>
</div>
)
}
export default GeneratePage