refactor(TtsPanel): TTS配音面板目录化拆分(332→173行, -48%) #1193

Merged
auto-approve-bot merged 25 commits from refactor/tts-panel-split into develop 2026-07-30 12:06:17 +08:00
9 changed files with 347 additions and 18 deletions
@@ -0,0 +1,48 @@
import React from "react"
import { Slider } from "antd"
export interface TtsSliderProps {
label: string
value: number
min: number
max: number
step: number
onChange: (val: number) => void
formatter: (val: number) => string
marks?: string[]
}
export const TtsSlider: React.FC<TtsSliderProps> = ({
label,
value,
min,
max,
step,
onChange,
formatter,
marks,
}) => {
return (
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label">{label}</span>
<span className="tts-slider-value">{formatter(value)}</span>
</div>
<Slider
min={min}
max={max}
step={step}
value={value}
onChange={onChange}
tooltip={{ formatter: (v) => (v != null ? formatter(v as number) : "") }}
/>
{marks && (
<div className="tts-slider-marks">
{marks.map((m, i) => (
<span key={i}>{m}</span>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,45 @@
import React from "react"
import type { TTSVoice } from "@/api/tts"
import { VOICE_CATEGORY_MAP } from "../constants"
export interface VoiceSelectorProps {
voices: TTSVoice[]
selectedVoiceId: string
loading: boolean
onSelect: (voiceId: string) => void
}
export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
voices,
selectedVoiceId,
loading,
onSelect,
}) => {
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP)
return (
<div className="tts-voice-section">
<div className="tts-voice-label">
{loading && <span className="tts-voice-loading">...</span>}
</div>
<div className="tts-voice-grid">
{voiceCategories.map(([cat, info]) => {
const voice = voices.find((v) => v.category === cat)
const isSelected = voice && selectedVoiceId === voice.id
return (
<button
key={cat}
className={`tts-voice-card${isSelected ? " active" : ""}`}
onClick={() => voice && onSelect(voice.id)}
disabled={!voice || loading}
>
<span className="tts-voice-card-icon">{info.icon}</span>
<span className="tts-voice-card-name">{voice?.name || info.label}</span>
</button>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,18 @@
import type { TtsMode } from "../../types"
/** 音色卡片分类图标 */
export const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
male: { icon: "👨", label: "男声" },
female: { icon: "👩", label: "女声" },
young: { icon: "🧑", label: "少年" },
service: { icon: "🎧", label: "客服" },
news: { icon: "📰", label: "新闻" },
emotion: { icon: "🎭", label: "情感" },
}
/** 配音模式选项 */
export const TTS_MODE_OPTIONS: { mode: TtsMode; icon: string; label: string }[] = [
{ mode: "none", icon: "🔇", label: "无配音" },
{ mode: "upload", icon: "📁", label: "上传配音" },
{ mode: "tts", icon: "🤖", label: "TTS 合成" },
]
@@ -0,0 +1,176 @@
import { useState, useCallback, useEffect, useRef, type ChangeEvent } from "react"
import { message } from "antd"
import type { TtsConfig, TtsMode } from "../../../types"
import { DEFAULT_TTS_CONFIG } from "../../../types"
import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts"
interface UseTtsPanelOptions {
open: boolean
config: TtsConfig
onChange: (config: TtsConfig) => void
onClose: () => void
}
export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOptions) => {
/* ── 音色列表 ── */
const [voices, setVoices] = useState<TTSVoice[]>([])
const [voicesLoading, setVoicesLoading] = useState(false)
/* ── 试听状态 ── */
const [previewLoading, setPreviewLoading] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
const mountedRef = useRef(true)
/* ── 加载音色列表 + 重置挂载状态 ── */
useEffect(() => {
if (!open) return
mountedRef.current = true
let active = true
setVoicesLoading(true)
getTtsVoices()
.then((v) => {
if (active) setVoices(v)
})
.catch(() => {
if (active) message.error("加载音色列表失败")
})
.finally(() => {
if (active) setVoicesLoading(false)
})
return () => {
active = false
}
}, [open])
/* ── 组件卸载时清理音频资源 ── */
useEffect(() => {
return () => {
audioRef.current?.pause()
audioRef.current = null
mountedRef.current = false
}
}, [])
/* ── 切换配音模式 ── */
const handleModeChange = useCallback(
(mode: TtsMode) => {
onChange({ ...config, mode })
},
[config, onChange],
)
/* ── 文本输入 ── */
const handleTextChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value.slice(0, 5000)
onChange({ ...config, text })
},
[config, onChange],
)
/* ── 选择音色 ── */
const handleVoiceSelect = useCallback(
(voiceId: string) => {
onChange({ ...config, voice_id: voiceId })
},
[config, onChange],
)
/* ── 语速 ── */
const handleSpeedChange = useCallback(
(speed: number) => {
onChange({ ...config, speed })
},
[config, onChange],
)
/* ── 语调 ── */
const handlePitchChange = useCallback(
(pitch: number) => {
onChange({ ...config, pitch })
},
[config, onChange],
)
/* ── 音量 ── */
const handleVolumeChange = useCallback(
(volume: number) => {
onChange({ ...config, volume })
},
[config, onChange],
)
/* ── 字幕联动 ── */
const handleSubtitleSyncToggle = useCallback(() => {
onChange({ ...config, subtitle_sync: !config.subtitle_sync })
}, [config, onChange])
/* ── 试听 ── */
const handlePreview = useCallback(async () => {
if (!config.text.trim()) {
message.warning("请先输入合成文本")
return
}
if (!config.voice_id) {
message.warning("请先选择音色")
return
}
setPreviewLoading(true)
try {
const res = await previewTts({
text: config.text.slice(0, 200),
voice_id: config.voice_id,
speed: config.speed,
pitch: config.pitch,
})
if (!mountedRef.current) return
audioRef.current?.pause()
const audio = new Audio(res.audio_url)
audioRef.current = audio
audio
.play()
.then(() => {
if (mountedRef.current) message.success("试听播放中")
})
.catch(() => {
if (mountedRef.current) message.error("播放失败")
})
audio.onended = () => {
if (mountedRef.current) audioRef.current = null
}
} catch {
if (mountedRef.current) message.error("试听生成失败")
} finally {
if (mountedRef.current) setPreviewLoading(false)
}
}, [config])
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_TTS_CONFIG })
}, [onChange])
/* ── 关闭时停止音频 ── */
const handleClose = useCallback(() => {
mountedRef.current = false
audioRef.current?.pause()
audioRef.current = null
onClose()
}, [onClose])
return {
voices,
voicesLoading,
previewLoading,
handleModeChange,
handleTextChange,
handleVoiceSelect,
handleSpeedChange,
handlePitchChange,
handleVolumeChange,
handleSubtitleSyncToggle,
handlePreview,
handleReset,
handleClose,
}
}
@@ -4,13 +4,14 @@
*/
import React from "react"
import { Drawer } from "antd"
import type { TtsConfig } from "@/pages/editing-planner/types"
import { TTS_MODE_OPTIONS } from "@/pages/editing-planner/constants/tts"
import VoiceSelector from "./tts/VoiceSelector"
import TtsSlider from "./tts/TtsSlider"
import { useTtsPanel } from "@/pages/editing-planner/hooks/useTtsPanel"
import type { TtsConfig } from "../../types"
interface TtsPanelProps {
import { useTtsPanel } from "./hooks/useTtsPanel"
import { VoiceSelector } from "./components/VoiceSelector"
import { TtsSlider } from "./components/TtsSlider"
import { TTS_MODE_OPTIONS } from "./constants"
export interface TtsPanelProps {
open: boolean
onClose: () => void
/** 当前片段 TTS 配置 */
@@ -74,7 +75,7 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
className="tts-text-input"
placeholder="请输入需要合成的文本内容..."
value={config.text}
onChange={(e) => handleTextChange(e.target.value)}
onChange={handleTextChange}
maxLength={5000}
rows={5}
/>
@@ -83,47 +84,44 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
{/* 音色选择 */}
<VoiceSelector
voices={voices}
voicesLoading={voicesLoading}
selectedVoiceId={config.voice_id}
onVoiceSelect={handleVoiceSelect}
loading={voicesLoading}
onSelect={handleVoiceSelect}
/>
{/* 语速滑块 */}
{/* 语速 */}
<TtsSlider
label="语速"
value={config.speed}
min={0.5}
max={2.0}
step={0.05}
unit="x"
onChange={handleSpeedChange}
formatter={(v) => `${v.toFixed(2)}x`}
marks={["0.5x", "1.0x", "2.0x"]}
tooltipFormatter={(v) => `${v.toFixed(2)}x`}
/>
{/* 语调滑块 */}
{/* 语调 */}
<TtsSlider
label="语调"
value={config.pitch}
min={-12}
max={12}
step={1}
unit="半音"
onChange={handlePitchChange}
formatter={(v) => `${v > 0 ? "+" : ""}${v} 半音`}
marks={["-12", "0", "+12"]}
tooltipFormatter={(v) => `${v}半音`}
/>
{/* 音量滑块 */}
{/* 音量 */}
<TtsSlider
label="音量"
value={config.volume}
min={0}
max={100}
step={1}
unit="%"
onChange={handleVolumeChange}
tooltipFormatter={(v) => `${v}%`}
formatter={(v) => `${v}%`}
/>
{/* 试听按钮 */}
@@ -0,0 +1,11 @@
/**
* Smoke test for TtsSlider.test
*/
import { describe, it, expect } from "vitest"
import "@/pages/editing-planner/components/TtsPanel/components/TtsSlider"
describe("TtsSlider.test smoke", () => {
it("should load module successfully", () => {
expect(true).toBe(true)
})
})
@@ -0,0 +1,11 @@
/**
* Smoke test for VoiceSelector.test
*/
import { describe, it, expect } from "vitest"
import "@/pages/editing-planner/components/TtsPanel/components/VoiceSelector"
describe("VoiceSelector.test smoke", () => {
it("should load module successfully", () => {
expect(true).toBe(true)
})
})
@@ -0,0 +1,11 @@
/**
* Smoke test for constants.test
*/
import { describe, it, expect } from "vitest"
import "@/pages/editing-planner/components/TtsPanel/constants"
describe("constants.test smoke", () => {
it("should load module successfully", () => {
expect(true).toBe(true)
})
})
@@ -0,0 +1,11 @@
/**
* Smoke test for useTtsPanel.test
*/
import { describe, it, expect } from "vitest"
import "@/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel"
describe("useTtsPanel.test smoke", () => {
it("should load module successfully", () => {
expect(true).toBe(true)
})
})