refactor(editing-planner): Phase 3 - extract business hooks #903
Executable → Regular
+178
-762
File diff suppressed because it is too large
Load Diff
Executable → Regular
@@ -2,7 +2,7 @@ import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
interface TopBarProps {
|
||||
currentTemplate: EditingTemplate | undefined
|
||||
currentTemplate: EditingTemplate | null
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
onUndo: () => void
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { Modal, message } from "antd"
|
||||
import type {
|
||||
ClipData,
|
||||
ClipType,
|
||||
TransitionConfig,
|
||||
SpeedConfig,
|
||||
TtsConfig,
|
||||
TrimConfig,
|
||||
} from "../types"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface UseClipOperationsParams {
|
||||
clips: ClipData[]
|
||||
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 片段操作 Hook
|
||||
* 片段增删改查、排序、裁剪、分割、转场、调速、TTS、配音选择
|
||||
*/
|
||||
export const useClipOperations = ({ clips, setClips }: UseClipOperationsParams) => {
|
||||
const [selectedClipId, setSelectedClipId] = useState<string | null>(null)
|
||||
|
||||
const selectedClip = useMemo(
|
||||
() => clips.find((c) => c.id === selectedClipId) || null,
|
||||
[clips, selectedClipId],
|
||||
)
|
||||
|
||||
/* ── 选中 / 重排 / 删除 ── */
|
||||
|
||||
const handleClipSelect = useCallback((clipId: string) => {
|
||||
setSelectedClipId(clipId)
|
||||
}, [])
|
||||
|
||||
const handleClipReorder = useCallback(
|
||||
(fromIdx: number, toIdx: number) => {
|
||||
setClips((prev) => {
|
||||
const updated = [...prev]
|
||||
const [moved] = updated.splice(fromIdx, 1)
|
||||
updated.splice(toIdx, 0, moved)
|
||||
return updated.map((c, i) => ({ ...c, order: i }))
|
||||
})
|
||||
},
|
||||
[setClips],
|
||||
)
|
||||
|
||||
const handleClipRemove = useCallback(
|
||||
(clipId: string) => {
|
||||
Modal.confirm({
|
||||
title: "删除片段",
|
||||
content: "确定要删除这个片段吗?此操作可通过撤销恢复。",
|
||||
okText: "删除",
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
onOk: () => {
|
||||
setClips((prev) => prev.filter((c) => c.id !== clipId))
|
||||
if (selectedClipId === clipId) setSelectedClipId(null)
|
||||
},
|
||||
})
|
||||
},
|
||||
[setClips, selectedClipId],
|
||||
)
|
||||
|
||||
const handleClipUpdate = useCallback(
|
||||
(clipId: string, data: Partial<ClipData>) => {
|
||||
setClips((prev) => prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)))
|
||||
},
|
||||
[setClips],
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加片段(不绑定任何素材)
|
||||
* 片段 = 时间规划 + 类型标记
|
||||
*/
|
||||
const handleAddClip = useCallback(
|
||||
(type: ClipType, duration: number) => {
|
||||
const newClip: ClipData = {
|
||||
id: `clip-${Date.now()}`,
|
||||
type,
|
||||
duration,
|
||||
startOffset: 0,
|
||||
order: clips.length,
|
||||
}
|
||||
setClips((prev) => [...prev, newClip])
|
||||
},
|
||||
[clips.length, setClips],
|
||||
)
|
||||
|
||||
/* ── 裁剪 / 分割 / 重置 ── */
|
||||
|
||||
const handleClipTrim = useCallback(
|
||||
(clipId: string, trimConfig: TrimConfig, newDuration: number) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === clipId ? { ...c, trim_config: trimConfig, duration: newDuration } : c,
|
||||
),
|
||||
)
|
||||
},
|
||||
[setClips],
|
||||
)
|
||||
|
||||
const handleClipSplit = useCallback(
|
||||
(clipId: string, splitRatio: number) => {
|
||||
setClips((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === clipId)
|
||||
if (idx === -1) return prev
|
||||
const clip = prev[idx]
|
||||
const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10
|
||||
if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev
|
||||
|
||||
// 前半段
|
||||
const firstHalf: ClipData = {
|
||||
...clip,
|
||||
duration: splitPoint,
|
||||
trim_config: clip.trim_config
|
||||
? {
|
||||
...clip.trim_config,
|
||||
end_time: clip.trim_config.start_time + splitPoint,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
|
||||
// 后半段
|
||||
const secondHalf: ClipData = {
|
||||
...clip,
|
||||
id: `clip-${Date.now()}`,
|
||||
duration: clip.duration - splitPoint,
|
||||
startOffset: clip.startOffset + splitPoint,
|
||||
trim_config: clip.trim_config
|
||||
? {
|
||||
...clip.trim_config,
|
||||
start_time: clip.trim_config.start_time + splitPoint,
|
||||
}
|
||||
: undefined,
|
||||
order: (clip.order ?? idx) + 1,
|
||||
}
|
||||
|
||||
const updated = [...prev]
|
||||
updated[idx] = firstHalf
|
||||
updated.splice(idx + 1, 0, secondHalf)
|
||||
return updated.map((c, i) => ({ ...c, order: i }))
|
||||
})
|
||||
},
|
||||
[setClips],
|
||||
)
|
||||
|
||||
const handleClipResetTrim = useCallback(
|
||||
(clipId: string) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.id !== clipId || !c.trim_config) return c
|
||||
const originalDuration = c.trim_config.original_duration ?? c.duration
|
||||
return {
|
||||
...c,
|
||||
duration: originalDuration,
|
||||
trim_config: undefined,
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
[setClips],
|
||||
)
|
||||
|
||||
/* ── 转场 / 调速 / TTS ── */
|
||||
|
||||
const handleTransitionChange = useCallback(
|
||||
(targetClipId: string | null, config: TransitionConfig) => {
|
||||
if (targetClipId) {
|
||||
handleClipUpdate(targetClipId, { transition: config })
|
||||
}
|
||||
// 同时更新全局默认转场(供新片段使用)—— 暂未实现全局默认
|
||||
},
|
||||
[handleClipUpdate],
|
||||
)
|
||||
|
||||
const handleSpeedChange = useCallback(
|
||||
(targetClipId: string | null, config: SpeedConfig) => {
|
||||
if (targetClipId) {
|
||||
handleClipUpdate(targetClipId, { speed: config })
|
||||
}
|
||||
},
|
||||
[handleClipUpdate],
|
||||
)
|
||||
|
||||
const handleApplySpeedAll = useCallback(
|
||||
(config: SpeedConfig) => {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })))
|
||||
message.success("已应用到所有片段")
|
||||
},
|
||||
[setClips],
|
||||
)
|
||||
|
||||
const handleTtsChange = useCallback(
|
||||
(targetClipId: string | null, ttsConfig: TtsConfig) => {
|
||||
if (!targetClipId) return
|
||||
handleClipUpdate(targetClipId, { tts_config: ttsConfig })
|
||||
},
|
||||
[handleClipUpdate],
|
||||
)
|
||||
|
||||
/** 为片段选择配音素材 */
|
||||
const handleClipVoiceSelect = useCallback(
|
||||
(clipId: string, asset: AssetItem | null) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === clipId
|
||||
? {
|
||||
...c,
|
||||
voice_asset_id: asset?.id ?? undefined,
|
||||
voice_file_url: asset?.file_url ?? undefined,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
)
|
||||
},
|
||||
[setClips],
|
||||
)
|
||||
|
||||
return {
|
||||
selectedClipId,
|
||||
setSelectedClipId,
|
||||
selectedClip,
|
||||
handleClipSelect,
|
||||
handleClipReorder,
|
||||
handleClipRemove,
|
||||
handleClipUpdate,
|
||||
handleAddClip,
|
||||
handleClipTrim,
|
||||
handleClipSplit,
|
||||
handleClipResetTrim,
|
||||
handleTransitionChange,
|
||||
handleSpeedChange,
|
||||
handleApplySpeedAll,
|
||||
handleTtsChange,
|
||||
handleClipVoiceSelect,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState, useCallback } from "react"
|
||||
|
||||
/**
|
||||
* 编辑器 Drawer 开关管理
|
||||
* 集中管理 11 个抽屉的开关状态 + 3 个目标片段 ID + 快捷打开方法
|
||||
*/
|
||||
export const useEditorDrawers = () => {
|
||||
/* ── 抽屉开关 ── */
|
||||
const [bgmDrawerOpen, setBgmDrawerOpen] = useState(false)
|
||||
const [subtitleDrawerOpen, setSubtitleDrawerOpen] = useState(false)
|
||||
const [transitionDrawerOpen, setTransitionDrawerOpen] = useState(false)
|
||||
const [speedDrawerOpen, setSpeedDrawerOpen] = useState(false)
|
||||
const [ttsDrawerOpen, setTtsDrawerOpen] = useState(false)
|
||||
const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false)
|
||||
const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false)
|
||||
const [pipDrawerOpen, setPipDrawerOpen] = useState(false)
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false)
|
||||
const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false)
|
||||
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false)
|
||||
|
||||
/* ── 目标片段 ID ── */
|
||||
/** 当前正在编辑转场的片段 ID(null = 全局默认转场) */
|
||||
const [transitionTargetClipId, setTransitionTargetClipId] = useState<string | null>(null)
|
||||
/** 当前正在调速的片段 ID */
|
||||
const [speedTargetClipId, setSpeedTargetClipId] = useState<string | null>(null)
|
||||
/** 当前正在配置 TTS 的片段 ID */
|
||||
const [ttsTargetClipId, setTtsTargetClipId] = useState<string | null>(null)
|
||||
|
||||
/* ── 快捷打开 ── */
|
||||
const openTransitionDrawer = useCallback((clipId?: string) => {
|
||||
setTransitionTargetClipId(clipId ?? null)
|
||||
setTransitionDrawerOpen(true)
|
||||
}, [])
|
||||
|
||||
const openSpeedDrawer = useCallback((clipId: string) => {
|
||||
setSpeedTargetClipId(clipId)
|
||||
setSpeedDrawerOpen(true)
|
||||
}, [])
|
||||
|
||||
const openTtsDrawer = useCallback((clipId: string) => {
|
||||
setTtsTargetClipId(clipId)
|
||||
setTtsDrawerOpen(true)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 开关 state
|
||||
bgmDrawerOpen,
|
||||
setBgmDrawerOpen,
|
||||
subtitleDrawerOpen,
|
||||
setSubtitleDrawerOpen,
|
||||
transitionDrawerOpen,
|
||||
setTransitionDrawerOpen,
|
||||
speedDrawerOpen,
|
||||
setSpeedDrawerOpen,
|
||||
ttsDrawerOpen,
|
||||
setTtsDrawerOpen,
|
||||
watermarkDrawerOpen,
|
||||
setWatermarkDrawerOpen,
|
||||
introOutroDrawerOpen,
|
||||
setIntroOutroDrawerOpen,
|
||||
pipDrawerOpen,
|
||||
setPipDrawerOpen,
|
||||
filterDrawerOpen,
|
||||
setFilterDrawerOpen,
|
||||
chromaKeyDrawerOpen,
|
||||
setChromaKeyDrawerOpen,
|
||||
stickerDrawerOpen,
|
||||
setStickerDrawerOpen,
|
||||
// 目标 ID
|
||||
transitionTargetClipId,
|
||||
speedTargetClipId,
|
||||
ttsTargetClipId,
|
||||
// 快捷方法
|
||||
openTransitionDrawer,
|
||||
openSpeedDrawer,
|
||||
openTtsDrawer,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react"
|
||||
|
||||
/**
|
||||
* 播放控制 Hook
|
||||
* 播放/暂停、rAF 帧推进、时间线缩放、seek
|
||||
*/
|
||||
export const usePlaybackControl = (totalDuration: number) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [pixelsPerSecond, setPixelsPerSecond] = useState(40)
|
||||
const prevFrameTimeRef = useRef<number | null>(null)
|
||||
|
||||
/** 播放头跳转 */
|
||||
const handleSeek = useCallback((time: number) => {
|
||||
setCurrentTime(Math.max(0, time))
|
||||
}, [])
|
||||
|
||||
/** 轨道缩放 */
|
||||
const handleZoomChange = useCallback((pps: number) => {
|
||||
setPixelsPerSecond(pps)
|
||||
}, [])
|
||||
|
||||
/** rAF 帧推进 — 播放时平滑更新播放头位置 */
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
prevFrameTimeRef.current = null
|
||||
return
|
||||
}
|
||||
let rafId: number
|
||||
const tick = (timestamp: number) => {
|
||||
if (prevFrameTimeRef.current !== null) {
|
||||
const delta = (timestamp - prevFrameTimeRef.current) / 1000
|
||||
setCurrentTime((prev) => {
|
||||
const next = prev + delta
|
||||
return next >= totalDuration ? totalDuration : next
|
||||
})
|
||||
}
|
||||
prevFrameTimeRef.current = timestamp
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
rafId = requestAnimationFrame(tick)
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId)
|
||||
prevFrameTimeRef.current = null
|
||||
}
|
||||
}, [isPlaying, totalDuration])
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
currentTime,
|
||||
pixelsPerSecond,
|
||||
handleSeek,
|
||||
handleZoomChange,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
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 type {
|
||||
ClipData,
|
||||
ClipType,
|
||||
TtsConfig,
|
||||
TtsMode,
|
||||
TrimConfig,
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
|
||||
interface UseTemplateManagementParams {
|
||||
urlTemplateId: string
|
||||
urlPlanId: string
|
||||
resetClips: (clips: ClipData[]) => void
|
||||
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
|
||||
setSelectedClipId: (id: string | null) => void
|
||||
setMediaAssets: (assets: MediaAsset[]) => void
|
||||
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
|
||||
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
|
||||
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
|
||||
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
|
||||
// 保存时需要的配置
|
||||
clips: ClipData[]
|
||||
totalDuration: number
|
||||
titleConfig: TitleConfig
|
||||
subtitleSettings: SubtitleStyleConfig
|
||||
bgmSettings: BgmMixConfig
|
||||
watermarkSettings: WatermarkConfig
|
||||
introOutroSettings: IntroOutroConfig
|
||||
pipSettings: PipConfig
|
||||
filterSettings: FilterConfig
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
stickerSettings: StickerConfig
|
||||
coverConfig: CoverConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板管理 Hook
|
||||
* 模板列表/分类/加载/保存/模式切换/筛选搜索 + 3 个 useEffect
|
||||
*/
|
||||
export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
const {
|
||||
urlTemplateId,
|
||||
urlPlanId,
|
||||
resetClips,
|
||||
setClips,
|
||||
setSelectedClipId,
|
||||
setMediaAssets,
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
clips,
|
||||
totalDuration,
|
||||
titleConfig,
|
||||
subtitleSettings,
|
||||
bgmSettings,
|
||||
watermarkSettings,
|
||||
introOutroSettings,
|
||||
pipSettings,
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
} = params
|
||||
|
||||
/* ── 模板列表 ── */
|
||||
const [templates, setTemplates] = useState<EditingTemplate[]>([])
|
||||
const [categories, setCategories] = useState<TemplateCategory[]>([])
|
||||
const [loadingTemplates, setLoadingTemplates] = useState(false)
|
||||
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(urlTemplateId || null)
|
||||
const [currentMode, setCurrentMode] = useState<TemplateMode>("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<string | null>(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 currentTemplate = templates.find((t) => t.id === loadedTemplateId) || null
|
||||
|
||||
/* ──────────── 加载 ──────────── */
|
||||
|
||||
/**
|
||||
* 并行加载模板列表、分类、素材库
|
||||
* 首次挂载时调用,三个接口无依赖关系,用 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("加载模板草稿失败"))
|
||||
}, [
|
||||
loadedPlanId,
|
||||
resetClips,
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
])
|
||||
|
||||
/* ──────────── 事件 ──────────── */
|
||||
|
||||
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 },
|
||||
}
|
||||
if (loadedTemplateId) {
|
||||
await updateEditingTemplate(loadedTemplateId, payload)
|
||||
} else {
|
||||
await createEditingTemplate(payload)
|
||||
}
|
||||
message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功")
|
||||
setSaveModalOpen(false)
|
||||
loadTemplates()
|
||||
} catch {
|
||||
message.error("保存失败")
|
||||
} finally {
|
||||
setSaveLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
templates,
|
||||
categories,
|
||||
loadingTemplates,
|
||||
loadedTemplateId,
|
||||
setLoadedTemplateId,
|
||||
currentMode,
|
||||
setCurrentMode,
|
||||
currentFilter,
|
||||
setCurrentFilter,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
saveModalOpen,
|
||||
setSaveModalOpen,
|
||||
draftName,
|
||||
setDraftName,
|
||||
draftCategory,
|
||||
setDraftCategory,
|
||||
draftTags,
|
||||
setDraftTags,
|
||||
saveLoading,
|
||||
filteredTemplates,
|
||||
currentTemplate,
|
||||
// methods
|
||||
loadTemplates,
|
||||
handleLoadTemplate,
|
||||
handleModeChange,
|
||||
handleOpenSaveModal,
|
||||
handleSave,
|
||||
}
|
||||
}
|
||||
|
||||
export { FILTER_CATEGORIES }
|
||||
Executable → Regular
+4
@@ -48,6 +48,10 @@ import "@/pages/editing-planner/types/subtitle"
|
||||
// Hooks
|
||||
import "@/pages/editing-planner/hooks/useUndoRedo"
|
||||
import "@/pages/editing-planner/hooks/useEditPlanClips"
|
||||
import "@/pages/editing-planner/hooks/useEditorDrawers"
|
||||
import "@/pages/editing-planner/hooks/usePlaybackControl"
|
||||
import "@/pages/editing-planner/hooks/useClipOperations"
|
||||
import "@/pages/editing-planner/hooks/useTemplateManagement"
|
||||
|
||||
describe("EditingPlanner module smoke test", () => {
|
||||
it("should load all editing-planner modules", () => {
|
||||
|
||||
Reference in New Issue
Block a user