diff --git a/apps/web/src/pages/editing-planner/hooks/template-management/usePlanLoading.ts b/apps/web/src/pages/editing-planner/hooks/template-management/usePlanLoading.ts new file mode 100644 index 000000000..8f15ce36c --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/template-management/usePlanLoading.ts @@ -0,0 +1,157 @@ +import { useEffect, type Dispatch, type SetStateAction } from "react" +import { message } from "antd" +import { getEditPlan, getEditPlanClips } from "@/api/template-editor" +import type { ClipData, ClipType, TtsConfig, TtsMode, TrimConfig } from "../../types" +import type { SubtitleStyleConfig } from "../../types/subtitle" +import type { TitleConfig } from "@/api/template-editor" +import type { BgmMixConfig } from "@/api/bgm" +import type { TransitionEffect } from "@/api/template-editor" +import type { CoverConfig } from "../../types" + +interface UsePlanLoadingOptions { + loadedPlanId: string | null + resetClips: (clips: ClipData[]) => void + setLoadedTemplateId: (id: string | null) => void + setDraftName: (name: string) => void + setTitleConfig: Dispatch> + setSubtitleSettings: Dispatch> + setBgmSettings: Dispatch> + setCoverConfig: Dispatch> +} + +/** + * 加载已有计划草稿数据到编辑器 + * 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置 + */ +export function usePlanLoading({ + loadedPlanId, + resetClips, + setLoadedTemplateId, + setDraftName, + setTitleConfig, + setSubtitleSettings, + setBgmSettings, + setCoverConfig, +}: UsePlanLoadingOptions) { + useEffect(() => { + if (!loadedPlanId) return + + Promise.all([ + getEditPlan(loadedPlanId), + getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({ + items: [], + total: 0, + })), + ]) + .then(([plan, clipsRes]) => { + setLoadedTemplateId(plan.template_id) + setDraftName(plan.name) + + const cfg = plan.config + if (cfg.title_config) { + setTitleConfig({ + ai_auto_select: cfg.title_config!.ai_auto_select, + content: cfg.title_config!.content, + position: cfg.title_config!.position, + font_preset: cfg.title_config!.font_preset, + font_size: cfg.title_config!.font_size, + font_color: cfg.title_config!.font_color || "#ffffff", + }) + } + if (cfg.subtitle_config) { + setSubtitleSettings((prev) => ({ + ...prev, + enabled: cfg.subtitle_config!.enabled, + position: (cfg.subtitle_config!.position || + "bottom") as SubtitleStyleConfig["position"], + font: cfg.subtitle_config!.font, + fontSize: cfg.subtitle_config!.size, + fontColor: cfg.subtitle_config!.color || "#ffffff", + animation: cfg.subtitle_config!.animation, + })) + } + if (cfg.bgm_config) { + setBgmSettings((prev) => ({ + ...prev, + enabled: cfg.bgm_config!.enabled, + music_id: cfg.bgm_config!.music_id || "", + })) + } + if (cfg.cover_config) { + setCoverConfig((prev: CoverConfig) => ({ + ...prev, + enabled: cfg.cover_config!.enabled ?? prev.enabled, + mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode, + frame_time: cfg.cover_config!.frame_time ?? prev.frame_time, + upload_url: cfg.cover_config!.upload_url || prev.upload_url, + thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url, + ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time, + })) + } + + /* 还原片段:优先从后端 clips 表,其次从 config.segments 兜底 */ + const backendClips = clipsRes?.items || [] + if (backendClips.length > 0) { + const sorted = [...backendClips].sort((a, b) => a.order - b.order) + const mapped: ClipData[] = sorted.map((clip) => ({ + id: clip.id, + template_segment_id: (clip.config?.template_segment_id as string) || "", + type: (clip.clip_type === "voiceover" ? "voice" : "pip") as ClipType, + duration: clip.duration || 3, + startOffset: 0, + script_text: clip.text_content || "", + order: clip.order, + media_asset_id: clip.asset_id || undefined, + transition: + clip.transition_effect && clip.transition_effect !== "none" + ? { + type: clip.transition_effect as TransitionEffect["type"], + duration: clip.transition_duration || 0.3, + } + : undefined, + speed: clip.playback_speed + ? { rate: clip.playback_speed, pitchCorrection: true } + : undefined, + tts_config: (clip.config?.tts_config as TtsConfig) || undefined, + trim_config: (clip.config?.trim_config as TrimConfig) || undefined, + })) + setTimeout(() => resetClips(mapped), 100) + } else if (cfg.segments && cfg.segments.length > 0) { + /* 兜底:从 config.segments 还原(老数据兼容) */ + const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({ + id: `seg-${idx}`, + template_segment_id: `seg-${idx}`, + type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType, + duration: (seg.duration_min + seg.duration_max) / 2, + startOffset: 0, + script_text: "", + order: seg.segment_order, + transition: seg.transition + ? { + type: seg.transition.type as TransitionEffect["type"], + duration: seg.transition.duration, + } + : undefined, + speed: seg.playback_speed + ? { rate: seg.playback_speed, pitchCorrection: true } + : undefined, + tts_config: seg.tts_config + ? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode } + : undefined, + trim_config: seg.trim_config || undefined, + })) + setTimeout(() => resetClips(mapped), 100) + } + }) + .catch(() => message.error("加载模板草稿失败")) + }, [ + loadedPlanId, + resetClips, + setLoadedTemplateId, + setDraftName, + setTitleConfig, + setSubtitleSettings, + setBgmSettings, + setCoverConfig, + ]) +} diff --git a/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateDetail.ts b/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateDetail.ts new file mode 100644 index 000000000..f3ce6c19f --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateDetail.ts @@ -0,0 +1,91 @@ +import { useEffect, type Dispatch, type SetStateAction } from "react" +import { message } from "antd" +import { getEditingTemplate, type TemplateMode } from "@/api/editing-planner" +import type { TitleConfig } from "@/api/template-editor" +import type { ClipData, ClipType } from "../../types" +import type { SubtitleStyleConfig } from "../../types/subtitle" +import type { BgmMixConfig } from "@/api/bgm" + +interface UseTemplateDetailOptions { + loadedTemplateId: string | null + resetClips: (clips: ClipData[]) => void + setCurrentMode: (mode: TemplateMode) => void + setTitleConfig: Dispatch> + setSubtitleSettings: Dispatch> + setBgmSettings: Dispatch> + setDraftName: (name: string) => void + setDraftCategory: (cat: string) => void + setDraftTags: (tags: string) => void +} + +/** + * 加载模板详情并初始化片段列表 + 配置 + */ +export function useTemplateDetail({ + loadedTemplateId, + resetClips, + setCurrentMode, + setTitleConfig, + setSubtitleSettings, + setBgmSettings, + setDraftName, + setDraftCategory, + setDraftTags, +}: UseTemplateDetailOptions) { + useEffect(() => { + if (!loadedTemplateId) return + getEditingTemplate(loadedTemplateId) + .then((tpl) => { + if (!tpl) return + setCurrentMode(tpl.mode) + + const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({ + id: seg.id || `seg-${idx}`, + template_segment_id: seg.id || `seg-${idx}`, + type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType, + duration: (seg.duration_min + seg.duration_max) / 2, + startOffset: 0, + script_text: "", + order: seg.segment_order, + })) + resetClips(mapped) + + setTitleConfig({ + ai_auto_select: tpl.title_config.ai_auto_select, + content: tpl.title_config.content, + position: tpl.title_config.position, + font_preset: tpl.title_config.font_preset, + font_size: tpl.title_config.font_size, + font_color: tpl.title_config.font_color || "#ffffff", + }) + setSubtitleSettings((prev) => ({ + ...prev, + enabled: tpl.subtitle_config.enabled, + position: (tpl.subtitle_config.position || "bottom") as SubtitleStyleConfig["position"], + font: tpl.subtitle_config.font, + fontSize: tpl.subtitle_config.size, + fontColor: tpl.subtitle_config.color || "#ffffff", + animation: tpl.subtitle_config.animation, + })) + setBgmSettings((prev) => ({ + ...prev, + enabled: tpl.bgm_config.enabled, + music_id: tpl.bgm_config.music_id || "", + })) + setDraftName(tpl.name) + setDraftCategory(tpl.category) + setDraftTags(tpl.tags.join(", ")) + }) + .catch(() => message.error("加载模板详情失败")) + }, [ + loadedTemplateId, + resetClips, + setCurrentMode, + setTitleConfig, + setSubtitleSettings, + setBgmSettings, + setDraftName, + setDraftCategory, + setDraftTags, + ]) +} diff --git a/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateList.ts b/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateList.ts new file mode 100644 index 000000000..2e09e1aca --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateList.ts @@ -0,0 +1,82 @@ +import { useState, useCallback, useEffect, useMemo } from "react" +import { message } from "antd" +import { + getEditingTemplates, + getTemplateCategories, + type EditingTemplate, + type TemplateCategory, +} from "@/api/editing-planner" +import { getMediaAssets, type MediaAsset } from "@/api/template-editor" +import { FILTER_CATEGORIES } from "../../constants" + +/** + * 模板列表 + 分类 + 筛选搜索 + */ +export function useTemplateList( + setMediaAssets: (assets: MediaAsset[]) => void, + initialTemplateId: string | null, +) { + const [templates, setTemplates] = useState([]) + const [categories, setCategories] = useState([]) + const [loadingTemplates, setLoadingTemplates] = useState(false) + const [currentFilter, setCurrentFilter] = useState("全部") + const [searchQuery, setSearchQuery] = useState("") + const [loadedTemplateId, setLoadedTemplateId] = useState(initialTemplateId) + + /** + * 并行加载模板列表、分类、素材库 + * 三个接口无依赖关系,用 Promise.all 并发 + */ + const loadTemplates = useCallback(async () => { + setLoadingTemplates(true) + try { + const [tpls, cats, assets] = await Promise.all([ + getEditingTemplates(), + getTemplateCategories(), + getMediaAssets(), + ]) + setTemplates(tpls) + setCategories(cats) + setMediaAssets(assets) + } catch { + message.error("加载模板失败") + } finally { + setLoadingTemplates(false) + } + }, [setMediaAssets]) + + useEffect(() => { + loadTemplates() + }, [loadTemplates]) + + const filteredTemplates = useMemo( + () => + templates.filter((t) => { + if (currentFilter !== "全部" && t.category !== currentFilter) return false + if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false + return true + }), + [templates, currentFilter, searchQuery], + ) + + const currentTemplate = useMemo( + () => templates.find((t) => t.id === loadedTemplateId) || null, + [templates, loadedTemplateId], + ) + + return { + templates, + categories, + loadingTemplates, + loadedTemplateId, + setLoadedTemplateId, + currentFilter, + setCurrentFilter, + searchQuery, + setSearchQuery, + filteredTemplates, + currentTemplate, + loadTemplates, + FILTER_CATEGORIES, + } +} diff --git a/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateSave.ts b/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateSave.ts new file mode 100644 index 000000000..0ba6f2223 --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/template-management/useTemplateSave.ts @@ -0,0 +1,184 @@ +import { useState, useCallback } from "react" +import { message } from "antd" +import { + createEditingTemplate, + updateEditingTemplate, + type SaveTemplatePayload, + type TemplateMode, +} from "@/api/editing-planner" +import type { + ClipData, + WatermarkConfig, + IntroOutroConfig, + PipConfig, + FilterConfig, + ChromaKeyConfig, + StickerConfig, +} from "../../types" +import type { SubtitleStyleConfig } from "../../types/subtitle" +import type { TitleConfig } from "@/api/template-editor" +import type { CoverConfig } from "../../types" +import type { BgmMixConfig } from "@/api/bgm" + +interface UseTemplateSaveOptions { + currentMode: TemplateMode + clips: ClipData[] + totalDuration: number + titleConfig: TitleConfig + subtitleSettings: SubtitleStyleConfig + bgmSettings: BgmMixConfig + watermarkSettings: WatermarkConfig + introOutroSettings: IntroOutroConfig + pipSettings: PipConfig + filterSettings: FilterConfig + chromaKeySettings: ChromaKeyConfig + stickerSettings: StickerConfig + coverConfig: CoverConfig + loadedTemplateId: string | null + loadTemplates: () => Promise +} + +/** + * 模板保存(创建/更新) + */ +export function useTemplateSave(options: UseTemplateSaveOptions) { + const { + currentMode, + clips, + totalDuration, + titleConfig, + subtitleSettings, + bgmSettings, + watermarkSettings, + introOutroSettings, + pipSettings, + filterSettings, + chromaKeySettings, + stickerSettings, + coverConfig, + loadedTemplateId, + loadTemplates, + } = options + + const [saveModalOpen, setSaveModalOpen] = useState(false) + const [draftName, setDraftName] = useState("") + const [draftCategory, setDraftCategory] = useState("") + const [draftTags, setDraftTags] = useState("") + const [saveLoading, setSaveLoading] = useState(false) + + const handleOpenSaveModal = useCallback(() => { + setSaveModalOpen(true) + }, []) + + const handleSave = useCallback(async () => { + if (!draftName.trim()) { + message.warning("请输入模板名称") + return + } + setSaveLoading(true) + try { + const payload: SaveTemplatePayload = { + name: draftName, + mode: currentMode, + category: draftCategory, + tags: draftTags + .split(",") + .map((t) => t.trim()) + .filter(Boolean), + title_config: titleConfig, + subtitle_config: { + enabled: subtitleSettings.enabled, + position: subtitleSettings.position, + font: subtitleSettings.font, + color: subtitleSettings.fontColor, + size: subtitleSettings.fontSize, + animation: subtitleSettings.animation, + }, + bgm_config: { + enabled: bgmSettings.enabled, + music_id: bgmSettings.music_id, + }, + estimated_duration: totalDuration, + segments: clips.map((c, i) => ({ + segment_order: i, + duration_min: Math.max(1, c.duration - 2), + duration_max: c.duration + 2, + material_type: c.type === "voice" ? "voiceover" : "video", + transition: c.transition + ? { type: c.transition.type, duration: c.transition.duration } + : undefined, + playback_speed: c.speed ? c.speed.rate : undefined, + tts_config: c.tts_config + ? { + mode: c.tts_config.mode, + text: c.tts_config.text, + voice_id: c.tts_config.voice_id, + speed: c.tts_config.speed, + pitch: c.tts_config.pitch, + volume: c.tts_config.volume, + subtitle_sync: c.tts_config.subtitle_sync, + } + : undefined, + trim_config: c.trim_config + ? { + start_time: c.trim_config.start_time, + end_time: c.trim_config.end_time, + } + : undefined, + })), + watermark_config: { ...watermarkSettings }, + intro_outro_config: { ...introOutroSettings }, + pip_config: { ...pipSettings }, + filter_config: { ...filterSettings }, + green_screen_config: { ...chromaKeySettings }, + sticker_config: { ...stickerSettings }, + cover_config: { ...coverConfig }, + } + if (loadedTemplateId) { + await updateEditingTemplate(loadedTemplateId, payload) + } else { + await createEditingTemplate(payload) + } + message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功") + setSaveModalOpen(false) + loadTemplates() + } catch { + message.error("保存失败") + } finally { + setSaveLoading(false) + } + }, [ + draftName, + draftCategory, + draftTags, + currentMode, + clips, + totalDuration, + titleConfig, + subtitleSettings, + bgmSettings, + watermarkSettings, + introOutroSettings, + pipSettings, + filterSettings, + chromaKeySettings, + stickerSettings, + coverConfig, + loadedTemplateId, + loadTemplates, + ]) + + return { + saveModalOpen, + setSaveModalOpen, + draftName, + setDraftName, + draftCategory, + setDraftCategory, + draftTags, + setDraftTags, + saveLoading, + handleOpenSaveModal, + handleSave, + } +} diff --git a/apps/web/src/pages/editing-planner/hooks/useTemplateManagement.ts b/apps/web/src/pages/editing-planner/hooks/useTemplateManagement.ts index b9db52ddf..e84787819 100644 --- a/apps/web/src/pages/editing-planner/hooks/useTemplateManagement.ts +++ b/apps/web/src/pages/editing-planner/hooks/useTemplateManagement.ts @@ -1,27 +1,8 @@ -import { useState, useCallback, useEffect, type Dispatch, type SetStateAction } from "react" -import { FILTER_CATEGORIES } from "../constants" -import { message } from "antd" -import type { - EditingTemplate, - TemplateCategory, - TemplateMode, - SaveTemplatePayload, -} from "@/api/editing-planner" -import { - getEditingTemplates, - getEditingTemplate, - createEditingTemplate, - updateEditingTemplate, - getTemplateCategories, -} from "@/api/editing-planner" -import type { MediaAsset, TitleConfig, TransitionEffect } from "@/api/template-editor" -import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/template-editor" +import { useState, useCallback, type Dispatch, type SetStateAction } from "react" +import type { TemplateMode } from "@/api/editing-planner" +import type { MediaAsset, TitleConfig } from "@/api/template-editor" import type { ClipData, - ClipType, - TtsConfig, - TtsMode, - TrimConfig, WatermarkConfig, IntroOutroConfig, PipConfig, @@ -32,6 +13,11 @@ import type { } from "../types" import type { SubtitleStyleConfig } from "../types/subtitle" import type { BgmMixConfig } from "@/api/bgm" +import { useTemplateList } from "./template-management/useTemplateList" +import { useTemplateDetail } from "./template-management/useTemplateDetail" +import { usePlanLoading } from "./template-management/usePlanLoading" +import { useTemplateSave } from "./template-management/useTemplateSave" +import { FILTER_CATEGORIES } from "../constants" interface UseTemplateManagementParams { urlTemplateId: string @@ -44,7 +30,6 @@ interface UseTemplateManagementParams { setSubtitleSettings: Dispatch> setBgmSettings: Dispatch> setCoverConfig: Dispatch> - // 保存时需要的配置 clips: ClipData[] totalDuration: number titleConfig: TitleConfig @@ -61,7 +46,7 @@ interface UseTemplateManagementParams { /** * 模板管理 Hook - * 模板列表/分类/加载/保存/模式切换/筛选搜索 + 3 个 useEffect + * 模板列表/分类/加载/保存/模式切换/筛选搜索 */ export const useTemplateManagement = (params: UseTemplateManagementParams) => { const { @@ -89,346 +74,101 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => { coverConfig, } = params - /* ── 模板列表 ── */ - const [templates, setTemplates] = useState([]) - const [categories, setCategories] = useState([]) - const [loadingTemplates, setLoadingTemplates] = useState(false) - const [loadedTemplateId, setLoadedTemplateId] = useState(urlTemplateId || null) const [currentMode, setCurrentMode] = useState("pip") - - /* ── 左栏筛选 ── */ - const [currentFilter, setCurrentFilter] = useState("全部") - const [searchQuery, setSearchQuery] = useState("") - - /* ── 保存弹窗 ── */ - const [saveModalOpen, setSaveModalOpen] = useState(false) - const [draftName, setDraftName] = useState("") - const [draftCategory, setDraftCategory] = useState("") - const [draftTags, setDraftTags] = useState("") - const [saveLoading, setSaveLoading] = useState(false) - - /* ── 计划 ID(从 URL 传入,不变) ── */ const [loadedPlanId] = useState(urlPlanId || null) - /* ── 计算 ── */ - const filteredTemplates = templates.filter((t) => { - if (currentFilter !== "全部" && t.category !== currentFilter) return false - if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false - return true + /* ── 模板列表 ── */ + const { + templates, + categories, + loadingTemplates, + loadedTemplateId, + setLoadedTemplateId, + currentFilter, + setCurrentFilter, + searchQuery, + setSearchQuery, + filteredTemplates, + currentTemplate, + loadTemplates, + } = useTemplateList(setMediaAssets, urlTemplateId || null) + + /* ── 保存 ── */ + const { + saveModalOpen, + setSaveModalOpen, + draftName, + setDraftName, + draftCategory, + setDraftCategory, + draftTags, + setDraftTags, + saveLoading, + handleOpenSaveModal, + handleSave, + } = useTemplateSave({ + currentMode, + clips, + totalDuration, + titleConfig, + subtitleSettings, + bgmSettings, + watermarkSettings, + introOutroSettings, + pipSettings, + filterSettings, + chromaKeySettings, + stickerSettings, + coverConfig, + loadedTemplateId, + loadTemplates, }) - const currentTemplate = templates.find((t) => t.id === loadedTemplateId) || null + /* ── 模板详情加载 ── */ + useTemplateDetail({ + loadedTemplateId, + resetClips, + setCurrentMode, + setTitleConfig, + setSubtitleSettings, + setBgmSettings, + setDraftName, + setDraftCategory, + setDraftTags, + }) - /* ──────────── 加载 ──────────── */ - - /** - * 并行加载模板列表、分类、素材库 - * 首次挂载时调用,三个接口无依赖关系,用 Promise.all 并发 - */ - const loadTemplates = useCallback(async () => { - setLoadingTemplates(true) - try { - const [tpls, cats, assets] = await Promise.all([ - getEditingTemplates(), - getTemplateCategories(), - getMediaAssets(), - ]) - setTemplates(tpls) - setCategories(cats) - setMediaAssets(assets) - } catch { - message.error("加载模板失败") - } finally { - setLoadingTemplates(false) - } - }, [setMediaAssets]) - - useEffect(() => { - loadTemplates() - }, [loadTemplates]) - - /** - * 加载模板详情并初始化片段列表 - * 将后端 segments 映射为前端 ClipData,取 duration_min/max 均值作为默认时长 - * 同时还原标题/字幕/BGM 配置 - */ - useEffect(() => { - if (!loadedTemplateId) return - getEditingTemplate(loadedTemplateId) - .then((tpl) => { - if (!tpl) return - setCurrentMode(tpl.mode) - const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({ - id: seg.id || `seg-${idx}`, - template_segment_id: seg.id || `seg-${idx}`, - type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType, - duration: (seg.duration_min + seg.duration_max) / 2, - startOffset: 0, - script_text: "", - order: seg.segment_order, - })) - resetClips(mapped) - - setTitleConfig({ - ai_auto_select: tpl.title_config.ai_auto_select, - content: tpl.title_config.content, - position: tpl.title_config.position, - font_preset: tpl.title_config.font_preset, - font_size: tpl.title_config.font_size, - font_color: tpl.title_config.font_color || "#ffffff", - }) - setSubtitleSettings((prev) => ({ - ...prev, - enabled: tpl.subtitle_config.enabled, - position: (tpl.subtitle_config.position || "bottom") as SubtitleStyleConfig["position"], - font: tpl.subtitle_config.font, - fontSize: tpl.subtitle_config.size, - fontColor: tpl.subtitle_config.color || "#ffffff", - animation: tpl.subtitle_config.animation, - })) - setBgmSettings((prev) => ({ - ...prev, - enabled: tpl.bgm_config.enabled, - music_id: tpl.bgm_config.music_id || "", - })) - setDraftName(tpl.name) - setDraftCategory(tpl.category) - setDraftTags(tpl.tags.join(", ")) - }) - .catch(() => message.error("加载模板详情失败")) - }, [loadedTemplateId, resetClips, setTitleConfig, setSubtitleSettings, setBgmSettings]) - - /** - * 加载已有模板草稿数据到编辑器 - * 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置 - */ - useEffect(() => { - if (!loadedPlanId) return - - // 并行加载计划基本信息 + 片段列表 - Promise.all([ - getEditPlan(loadedPlanId), - getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({ - items: [], - total: 0, - })), - ]) - .then(([plan, clipsRes]) => { - // 设置关联的模板(触发模板加载 effect) - setLoadedTemplateId(plan.template_id) - - // 还原基本信息 - setDraftName(plan.name) - - // 还原 config 中的编辑器状态 - const cfg = plan.config - if (cfg.title_config) { - setTitleConfig({ - ai_auto_select: cfg.title_config!.ai_auto_select, - content: cfg.title_config!.content, - position: cfg.title_config!.position, - font_preset: cfg.title_config!.font_preset, - font_size: cfg.title_config!.font_size, - font_color: cfg.title_config!.font_color || "#ffffff", - }) - } - if (cfg.subtitle_config) { - setSubtitleSettings((prev) => ({ - ...prev, - enabled: cfg.subtitle_config!.enabled, - position: (cfg.subtitle_config!.position || - "bottom") as SubtitleStyleConfig["position"], - font: cfg.subtitle_config!.font, - fontSize: cfg.subtitle_config!.size, - fontColor: cfg.subtitle_config!.color || "#ffffff", - animation: cfg.subtitle_config!.animation, - })) - } - if (cfg.bgm_config) { - setBgmSettings((prev) => ({ - ...prev, - enabled: cfg.bgm_config!.enabled, - music_id: cfg.bgm_config!.music_id || "", - })) - } - // 还原封面配置 - if (cfg.cover_config) { - setCoverConfig((prev) => ({ - ...prev, - enabled: cfg.cover_config!.enabled ?? prev.enabled, - mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode, - frame_time: cfg.cover_config!.frame_time ?? prev.frame_time, - upload_url: cfg.cover_config!.upload_url || prev.upload_url, - thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url, - ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time, - })) - } - - // 还原片段:优先从后端 clips 表,其次从 config.segments 兜底 - const backendClips = clipsRes?.items || [] - if (backendClips.length > 0) { - // 从后端 clips 表还原 - const sorted = [...backendClips].sort((a, b) => a.order - b.order) - const mapped: ClipData[] = sorted.map((clip) => ({ - id: clip.id, - template_segment_id: (clip.config?.template_segment_id as string) || "", - type: (clip.clip_type === "voiceover" ? "voice" : "pip") as ClipType, - duration: clip.duration || 3, - startOffset: 0, - script_text: clip.text_content || "", - order: clip.order, - media_asset_id: clip.asset_id || undefined, - transition: - clip.transition_effect && clip.transition_effect !== "none" - ? { - type: clip.transition_effect as TransitionEffect["type"], - duration: clip.transition_duration || 0.3, - } - : undefined, - speed: clip.playback_speed - ? { rate: clip.playback_speed, pitchCorrection: true } - : undefined, - tts_config: (clip.config?.tts_config as TtsConfig) || undefined, - trim_config: (clip.config?.trim_config as TrimConfig) || undefined, - })) - setTimeout(() => resetClips(mapped), 100) - } else if (cfg.segments && cfg.segments.length > 0) { - // 兜底:从 config.segments 还原(老数据兼容) - const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({ - id: `seg-${idx}`, - template_segment_id: `seg-${idx}`, - type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType, - duration: (seg.duration_min + seg.duration_max) / 2, - startOffset: 0, - script_text: "", - order: seg.segment_order, - transition: seg.transition - ? { - type: seg.transition.type as TransitionEffect["type"], - duration: seg.transition.duration, - } - : undefined, - speed: seg.playback_speed - ? { rate: seg.playback_speed, pitchCorrection: true } - : undefined, - tts_config: seg.tts_config - ? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode } - : undefined, - trim_config: seg.trim_config || undefined, - })) - setTimeout(() => resetClips(mapped), 100) - } - }) - .catch(() => message.error("加载模板草稿失败")) - }, [ + /* ── 计划草稿加载 ── */ + usePlanLoading({ loadedPlanId, resetClips, + setLoadedTemplateId, + setDraftName, setTitleConfig, setSubtitleSettings, setBgmSettings, setCoverConfig, - ]) + }) - /* ──────────── 事件 ──────────── */ + /* ── 事件 ── */ + const handleLoadTemplate = useCallback( + (templateId: string) => { + setLoadedTemplateId(templateId) + setSelectedClipId(null) + }, + [setLoadedTemplateId, setSelectedClipId], + ) - const handleLoadTemplate = (templateId: string) => { - setLoadedTemplateId(templateId) - setSelectedClipId(null) - } - - const handleModeChange = (mode: TemplateMode) => { - setCurrentMode(mode) - // 切换纯单类型模式时,自动转换所有已有片段的类型 - if (mode === "voice_over") { - setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const }))) - } else if (mode === "pip") { - setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const }))) - } - // 混合模式(voice_pip)和一镜到底(one_take)不自动转换,保留原有类型 - } - - const handleOpenSaveModal = () => { - setSaveModalOpen(true) - } - - const handleSave = async () => { - if (!draftName.trim()) { - message.warning("请输入模板名称") - return - } - setSaveLoading(true) - try { - const payload: SaveTemplatePayload = { - name: draftName, - mode: currentMode, - category: draftCategory, - tags: draftTags - .split(",") - .map((t) => t.trim()) - .filter(Boolean), - title_config: titleConfig, - subtitle_config: { - enabled: subtitleSettings.enabled, - position: subtitleSettings.position, - font: subtitleSettings.font, - color: subtitleSettings.fontColor, - size: subtitleSettings.fontSize, - animation: subtitleSettings.animation, - }, - bgm_config: { - enabled: bgmSettings.enabled, - music_id: bgmSettings.music_id, - }, - estimated_duration: totalDuration, - segments: clips.map((c, i) => ({ - segment_order: i, - duration_min: Math.max(1, c.duration - 2), - duration_max: c.duration + 2, - material_type: c.type === "voice" ? "voiceover" : "video", - transition: c.transition - ? { type: c.transition.type, duration: c.transition.duration } - : undefined, - playback_speed: c.speed ? c.speed.rate : undefined, - tts_config: c.tts_config - ? { - mode: c.tts_config.mode, - text: c.tts_config.text, - voice_id: c.tts_config.voice_id, - speed: c.tts_config.speed, - pitch: c.tts_config.pitch, - volume: c.tts_config.volume, - subtitle_sync: c.tts_config.subtitle_sync, - } - : undefined, - trim_config: c.trim_config - ? { - start_time: c.trim_config.start_time, - end_time: c.trim_config.end_time, - } - : undefined, - })), - watermark_config: { ...watermarkSettings }, - intro_outro_config: { ...introOutroSettings }, - pip_config: { ...pipSettings }, - filter_config: { ...filterSettings }, - green_screen_config: { ...chromaKeySettings }, - sticker_config: { ...stickerSettings }, - cover_config: { ...coverConfig }, + const handleModeChange = useCallback( + (mode: TemplateMode) => { + setCurrentMode(mode) + if (mode === "voice_over") { + setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const }))) + } else if (mode === "pip") { + setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const }))) } - if (loadedTemplateId) { - await updateEditingTemplate(loadedTemplateId, payload) - } else { - await createEditingTemplate(payload) - } - message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功") - setSaveModalOpen(false) - loadTemplates() - } catch { - message.error("保存失败") - } finally { - setSaveLoading(false) - } - } + }, + [setClips], + ) return { // state