/** * 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, } }