diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 7d2e81b8a..a918d13d6 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -2,230 +2,93 @@ * 智能剪辑页面 — V21 原型 1:1 还原 * 7 步向导:选择模板 → 选择素材 → 生成预览 → 选择标题 → 选择配音 → 选择封面 → 确认生成 * 左右布局:左侧 generate-form + 右侧 generate-preview - * 主组件仅保留共享状态、步骤切换、整体布局 - * 各 Step 的 UI 与业务逻辑拆分至 components/ + hooks/ - * 生成核心逻辑封装在 useGenerateVideo hook + * 主组件仅保留整体布局与事件编排 + * 状态管理 → hooks/useGenerateFormState + * 步骤导航 → hooks/useStepNavigation + * 步骤内容 → components/GenerateStepContent + * 底部按钮 → components/GenerateStepActions + * 生成核心逻辑 → hooks/useGenerateVideo */ -import React, { useState, useEffect, useMemo } from "react" -import { useQuery } from "@tanstack/react-query" -import { message, Modal } from "antd" -import { ThunderboltOutlined } from "@ant-design/icons" -import type { GeneratedVideo, TitleConfig } from "@/api/template-editor" -import { getEditPlan } from "@/api/template-editor" -import type { CoverConfig } from "../editing-planner/types" -import { getEditingTemplates } from "@/api/editing-planner" -import type { PresetVoiceItem } from "@/api/voices" -import { fetchPresetVoices } from "@/api/voices" +import React from "react" +import { Modal, message } from "antd" +import { useNavigate } from "react-router-dom" import type { VoiceClone } from "@/api/voice-clone" import { useCloneProgress } from "@/hooks/useCloneProgress" import CloneModal from "@/components/voice/CloneModal" -import { useSearchParams, useNavigate } from "react-router-dom" import GenerateHeader from "./components/GenerateHeader" import GenerateStepsBar from "./components/GenerateStepsBar" import GenerateResultPanel from "./components/GenerateResultPanel" -import Step1TemplateSelect from "./components/Step1TemplateSelect" -import Step2MaterialSelect from "./components/Step2MaterialSelect" -import Step3GeneratePreview from "./components/Step3GeneratePreview" -import Step4TitleSettings from "./components/Step4TitleSettings" -import Step5VoiceSelect from "./components/Step5VoiceSelect" -import Step6CoverSettings from "./components/Step6CoverSettings" -import Step7ConfirmGenerate from "./components/Step7ConfirmGenerate" -import { DEFAULT_COVER_SETTINGS } from "./constants" -import type { TitleSettings } from "./types" +import GenerateStepContent from "./components/GenerateStepContent" +import GenerateStepActions from "./components/GenerateStepActions" +import { useGenerateFormState } from "./hooks/useGenerateFormState" +import { useStepNavigation } from "./hooks/useStepNavigation" import { useGenerateVideo } from "./hooks/useGenerateVideo" import "./generate.css" -const DEFAULT_TITLE_SETTINGS: TitleSettings = { - aiAutoSelect: false, - title: "", - position: "bottom", - font: "思源黑体", - size: 28, - bold: true, - italic: false, - stroke: true, - shadow: false, - color: "#ffffff", -} - const GeneratePage: React.FC = () => { const navigate = useNavigate() - /* ── 步骤状态 ── */ - const [currentStep, setCurrentStep] = useState(1) + /* ── 表单状态 ── */ + const formState = useGenerateFormState() + const { + currentStep, + setCurrentStep, + selectedTemplate, + setSelectedTemplate, + userTemplates, + selectedMaterials, + setSelectedMaterials, + materialMode, + setMaterialMode, + smartSelectedIds, + setSmartSelectedIds, + titleSettings, + setTitleSettings, + coverSettings, + setCoverSettings, + selectedVoice, + setSelectedVoice, + voiceMode, + setVoiceMode, + selectedClonedVoice, + setSelectedClonedVoice, + presetVoices, + cloneModalOpen, + setCloneModalOpen, + generateCount, + setGenerateCount, + videoRatio, + duration, + style, + autoSubtitles, + bgm, + editPlanId, + previewVideo, + setPreviewVideo, + previewModalOpen, + setPreviewModalOpen, + } = formState - /* ── 模板(从 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]) - - /* ── 素材(共享:step2 选择、step7 展示、生成使用) ── */ - const [selectedMaterials, setSelectedMaterials] = useState([]) - const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual") - const [smartSelectedIds, setSmartSelectedIds] = useState([]) - - /* ── 标题设置(共享:step4 编辑、step7 展示、生成使用) ── */ - const [titleSettings, setTitleSettings] = useState(DEFAULT_TITLE_SETTINGS) - - /* ── 封面设置(共享:step6 编辑、step7 展示、生成使用) ── */ - const [coverSettings, setCoverSettings] = useState(DEFAULT_COVER_SETTINGS) - - /* 当选中模板开启了「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]) - - /* ── 配音(共享:step5 选择、step7 展示、生成使用) ── */ - const [selectedVoice, setSelectedVoice] = useState("") - const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset") - const [selectedClonedVoice, setSelectedClonedVoice] = useState("") - - /* ── 预置音色 API(共享:step5 选择、step7 展示) ── */ - const { data: presetVoicesData } = useQuery({ - queryKey: ["preset-voices"], - queryFn: fetchPresetVoices, - }) - const presetVoices: PresetVoiceItem[] = useMemo( - () => presetVoicesData?.items ?? [], - [presetVoicesData], - ) - - /* ── 克隆声音(共享:step5 管理、step7 展示) ── */ - const [cloneModalOpen, setCloneModalOpen] = useState(false) + /* ── 克隆声音 ── */ const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress() - /* ── 生成数量 ── */ - const [generateCount, setGenerateCount] = useState(1) - - /* ── 高级设置(隐藏但保留) ── */ - const [videoRatio] = useState("16:9") - const [duration] = useState(30) - const [style] = useState("business") - const [autoSubtitles] = useState(true) - const [bgm] = useState(true) - - /* ── 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]) - - /* ── 克隆成功回调 ── */ const handleCloneSuccess = (voice: VoiceClone) => { addClone(voice) setCloneModalOpen(false) message.success("音色克隆成功!") } + /* ── 步骤导航 ── */ + const { goNext, goPrev } = useStepNavigation({ + currentStep, + setCurrentStep, + selectedTemplate, + materialMode, + selectedMaterials, + smartSelectedIds, + titleSettings, + }) + /* ── 视频生成核心逻辑 ── */ const { generating, @@ -256,136 +119,6 @@ const GeneratePage: React.FC = () => { generateCount, }) - /* ── 预览弹窗状态 ── */ - const [previewVideo, setPreviewVideo] = useState(null) - const [previewModalOpen, setPreviewModalOpen] = useState(false) - - /* ── 步骤导航 ── */ - const goNext = () => { - 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) - } - } - - const goPrev = () => { - if (currentStep > 1) { - setCurrentStep((s) => s - 1) - } - } - - /* ── 渲染当前步骤 ── */ - const renderCurrentStep = () => { - switch (currentStep) { - case 1: - return ( - - ) - case 2: - return ( - - ) - case 3: - return ( - - ) - case 4: - return ( - - ) - case 5: - return ( - - ) - case 6: - return ( - - ) - case 7: - return ( - - ) - default: - return null - } - } - /* ================================================================ 渲染 — 主页面 ================================================================ */ @@ -402,35 +135,55 @@ const GeneratePage: React.FC = () => {
{/* ════ 左侧:表单区 ════ */}
- {/* 当前步骤 */} - {renderCurrentStep()} + - {/* 底部操作按钮 */} -
- - {currentStep < 7 ? ( - - ) : ( - - )} -
+
{/* ════ 右侧:生成结果 ════ */} diff --git a/apps/web/src/pages/generate/components/GenerateStepActions.tsx b/apps/web/src/pages/generate/components/GenerateStepActions.tsx new file mode 100644 index 000000000..df010d39a --- /dev/null +++ b/apps/web/src/pages/generate/components/GenerateStepActions.tsx @@ -0,0 +1,55 @@ +/** + * GeneratePage 步骤底部操作按钮 + */ +import React from "react" +import { ThunderboltOutlined } from "@ant-design/icons" + +export interface GenerateStepActionsProps { + currentStep: number + onPrev: () => void + onNext: () => void + onGenerate: () => void + generating: boolean + generated: boolean + generateError: string | null +} + +export const GenerateStepActions: React.FC = ({ + currentStep, + onPrev, + onNext, + onGenerate, + generating, + generated, + generateError, +}) => { + return ( +
+ + {currentStep < 7 ? ( + + ) : ( + + )} +
+ ) +} + +export default GenerateStepActions diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx new file mode 100644 index 000000000..b0c3c8bdd --- /dev/null +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -0,0 +1,203 @@ +/** + * GeneratePage 步骤内容渲染 + * 根据当前步骤渲染对应的 Step 组件 + */ +import React from "react" +import type { EditingTemplate } from "@/api/editing-planner" +import type { PresetVoiceItem } from "@/api/voices" +import type { VoiceClone } from "@/api/voice-clone" +import type { CoverConfig } from "../../editing-planner/types" +import type { TitleSettings } from "../types" +import Step1TemplateSelect from "../components/Step1TemplateSelect" +import Step2MaterialSelect from "../components/Step2MaterialSelect" +import Step3GeneratePreview from "../components/Step3GeneratePreview" +import Step4TitleSettings from "../components/Step4TitleSettings" +import Step5VoiceSelect from "../components/Step5VoiceSelect" +import Step6CoverSettings from "../components/Step6CoverSettings" +import Step7ConfirmGenerate from "../components/Step7ConfirmGenerate" +import type { GeneratedVideo } from "@/api/template-editor" + +export interface GenerateStepContentProps { + currentStep: number + /* 模板 */ + userTemplates: EditingTemplate[] + selectedTemplate: string + onSelectTemplate: (id: string) => void + /* 素材 */ + materialMode: "manual" | "auto" + onMaterialModeChange: (mode: "manual" | "auto") => void + selectedMaterials: string[] + onSelectedMaterialsChange: (ids: string[]) => void + smartSelectedIds: string[] + onSmartSelectedIdsChange: (ids: string[]) => void + /* 标题 */ + titleSettings: TitleSettings + onTitleSettingsChange: (settings: TitleSettings) => void + /* 封面 */ + coverSettings: CoverConfig + onCoverSettingsChange: (settings: CoverConfig) => void + duration: number + /* 配音 */ + selectedVoice: string + onSelectedVoiceChange: (id: string) => void + voiceMode: "preset" | "custom" | "clone" + onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void + selectedClonedVoice: string + onSelectedClonedVoiceChange: (id: string) => void + clonedVoices: VoiceClone[] + addClone: (voice: VoiceClone) => void + hasProcessing: boolean + cloneModalOpen: boolean + onCloneModalOpenChange: (open: boolean) => void + /* 生成 */ + generateCount: number + onGenerateCountChange: (n: number) => void + generating: boolean + generated: boolean + generateError: string | null + progress: number + generatedVideos: GeneratedVideo[] + onRetry: () => void + onDismissError: () => void + /* 其他 */ + presetVoices: PresetVoiceItem[] + videoRatio: string +} + +export const GenerateStepContent: React.FC = (props) => { + const { + currentStep, + userTemplates, + selectedTemplate, + onSelectTemplate, + materialMode, + onMaterialModeChange, + selectedMaterials, + onSelectedMaterialsChange, + smartSelectedIds, + onSmartSelectedIdsChange, + titleSettings, + onTitleSettingsChange, + coverSettings, + onCoverSettingsChange, + duration, + selectedVoice, + onSelectedVoiceChange, + voiceMode, + onVoiceModeChange, + selectedClonedVoice, + onSelectedClonedVoiceChange, + clonedVoices, + addClone, + hasProcessing, + cloneModalOpen, + onCloneModalOpenChange, + generateCount, + onGenerateCountChange, + generating, + generated, + generateError, + progress, + generatedVideos, + onRetry, + onDismissError, + presetVoices, + videoRatio, + } = props + + switch (currentStep) { + case 1: + return ( + + ) + case 2: + return ( + + ) + case 3: + return ( + + ) + case 4: + return ( + + ) + case 5: + return ( + + ) + case 6: + return ( + + ) + case 7: + return ( + + ) + default: + return null + } +} + +export default GenerateStepContent diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState.ts new file mode 100644 index 000000000..acd8e1aec --- /dev/null +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState.ts @@ -0,0 +1,309 @@ +/** + * GeneratePage 表单状态管理 + * 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析 + */ +import { useState, useEffect, useMemo } from "react" +import { useQuery } from "@tanstack/react-query" +import { useSearchParams } from "react-router-dom" +import type { GeneratedVideo, TitleConfig } from "@/api/template-editor" +import type { EditingTemplate } from "@/api/editing-planner" +import { getEditPlan } from "@/api/template-editor" +import type { CoverConfig } from "../../editing-planner/types" +import { getEditingTemplates } from "@/api/editing-planner" +import type { PresetVoiceItem } from "@/api/voices" +import { fetchPresetVoices } from "@/api/voices" +import { DEFAULT_COVER_SETTINGS } from "../constants" +import type { TitleSettings } from "../types" + +const DEFAULT_TITLE_SETTINGS: TitleSettings = { + aiAutoSelect: false, + title: "", + position: "bottom", + font: "思源黑体", + size: 28, + bold: true, + italic: false, + stroke: true, + shadow: false, + color: "#ffffff", +} + +export interface GenerateFormState { + /* 步骤 */ + currentStep: number + setCurrentStep: (step: number | ((prev: number) => number)) => void + + /* 模板 */ + selectedTemplate: string + setSelectedTemplate: (id: string) => void + userTemplates: EditingTemplate[] + + /* 素材 */ + selectedMaterials: string[] + setSelectedMaterials: (ids: string[]) => void + materialMode: "manual" | "auto" + setMaterialMode: (mode: "manual" | "auto") => void + smartSelectedIds: string[] + setSmartSelectedIds: (ids: string[]) => void + + /* 标题 */ + titleSettings: TitleSettings + setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void + + /* 封面 */ + coverSettings: CoverConfig + setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void + + /* 配音 */ + selectedVoice: string + setSelectedVoice: (id: string) => void + voiceMode: "preset" | "custom" | "clone" + setVoiceMode: (mode: "preset" | "custom" | "clone") => void + selectedClonedVoice: string + setSelectedClonedVoice: (id: string) => void + presetVoices: PresetVoiceItem[] + + /* 克隆弹窗 */ + cloneModalOpen: boolean + setCloneModalOpen: (open: boolean) => void + + /* 生成数量 */ + generateCount: number + setGenerateCount: (n: number) => void + + /* 高级设置 */ + videoRatio: string + duration: number + style: string + autoSubtitles: boolean + bgm: boolean + + /* URL 参数 */ + editPlanId: string | null + planConfigStr: string | null + + /* 预览弹窗 */ + previewVideo: GeneratedVideo | null + setPreviewVideo: (v: GeneratedVideo | null) => void + previewModalOpen: boolean + setPreviewModalOpen: (open: boolean) => void +} + +export const useGenerateFormState = (): GenerateFormState => { + const [searchParams] = useSearchParams() + const editPlanId = searchParams.get("edit_plan_id") + const planConfigStr = searchParams.get("plan_config") + + /* ── 步骤状态 ── */ + 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([]) + const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual") + const [smartSelectedIds, setSmartSelectedIds] = useState([]) + + /* ── 标题设置 ── */ + const [titleSettings, setTitleSettings] = useState(DEFAULT_TITLE_SETTINGS) + + /* ── 封面设置 ── */ + const [coverSettings, setCoverSettings] = useState(DEFAULT_COVER_SETTINGS) + + /* 当选中模板开启了「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("") + const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset") + const [selectedClonedVoice, setSelectedClonedVoice] = useState("") + + /* ── 预置音色 API ── */ + const { data: presetVoicesData } = useQuery({ + queryKey: ["preset-voices"], + queryFn: fetchPresetVoices, + }) + const presetVoices: PresetVoiceItem[] = useMemo( + () => presetVoicesData?.items ?? [], + [presetVoicesData], + ) + + /* ── 克隆声音弹窗 ── */ + const [cloneModalOpen, setCloneModalOpen] = useState(false) + + /* ── 生成数量 ── */ + const [generateCount, setGenerateCount] = useState(1) + + /* ── 高级设置(隐藏但保留) ── */ + const [videoRatio] = useState("16:9") + const [duration] = useState(30) + const [style] = useState("business") + const [autoSubtitles] = useState(true) + const [bgm] = useState(true) + + /* ── 预览弹窗 ── */ + const [previewVideo, setPreviewVideo] = useState(null) + const [previewModalOpen, setPreviewModalOpen] = useState(false) + + /** 解析 plan_config 并自动填充表单 */ + useEffect(() => { + if (!planConfigStr) return + try { + const config = JSON.parse(planConfigStr) as { + title_config?: { + content?: string + ai_auto_select?: boolean + position?: string + font_preset?: string + font_size?: number + font_color?: string + } + 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]) + + return { + currentStep, + setCurrentStep, + selectedTemplate, + setSelectedTemplate, + userTemplates, + selectedMaterials, + setSelectedMaterials, + materialMode, + setMaterialMode, + smartSelectedIds, + setSmartSelectedIds, + titleSettings, + setTitleSettings, + coverSettings, + setCoverSettings, + selectedVoice, + setSelectedVoice, + voiceMode, + setVoiceMode, + selectedClonedVoice, + setSelectedClonedVoice, + presetVoices, + cloneModalOpen, + setCloneModalOpen, + generateCount, + setGenerateCount, + videoRatio, + duration, + style, + autoSubtitles, + bgm, + editPlanId, + planConfigStr, + previewVideo, + setPreviewVideo, + previewModalOpen, + setPreviewModalOpen, + } +} diff --git a/apps/web/src/pages/generate/hooks/useStepNavigation.ts b/apps/web/src/pages/generate/hooks/useStepNavigation.ts new file mode 100644 index 000000000..db6f0be2f --- /dev/null +++ b/apps/web/src/pages/generate/hooks/useStepNavigation.ts @@ -0,0 +1,63 @@ +/** + * GeneratePage 步骤导航 + * 管理步骤切换与各步骤的前置校验 + */ +import { message } from "antd" +import type { TitleSettings } from "../types" + +export interface UseStepNavigationOptions { + currentStep: number + setCurrentStep: (step: number | ((prev: number) => number)) => void + selectedTemplate: string + materialMode: "manual" | "auto" + selectedMaterials: string[] + smartSelectedIds: string[] + titleSettings: TitleSettings +} + +export interface UseStepNavigationReturn { + goNext: () => void + goPrev: () => void +} + +export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNavigationReturn => { + const { + currentStep, + setCurrentStep, + selectedTemplate, + materialMode, + selectedMaterials, + smartSelectedIds, + titleSettings, + } = options + + const goNext = () => { + 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) + } + } + + const goPrev = () => { + if (currentStep > 1) { + setCurrentStep((s) => s - 1) + } + } + + return { goNext, goPrev } +}