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 — 抽离常量/类型/UI组件(3036→2682行)
2681 lines
97 KiB
TypeScript
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
|