refactor(voices): 拆分 TtsModal 为子组件(258→53行, -79%) #1051

Closed
xiaoxia wants to merge 1 commits from refactor/tts-modal-voices into develop
10 changed files with 338 additions and 221 deletions
+16 -221
View File
@@ -1,27 +1,12 @@
import React from "react"
import { RobotOutlined } from "@ant-design/icons"
import { Modal, message } from "antd"
import { type PresetVoiceDisplay } from "@/pages/voices/types"
import { genderLabel } from "@/pages/voices/utils/format"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
export interface TtsModalProps {
open: boolean
ttsText: string
ttsVoiceId: string
ttsSpeed: number
ttsStatus: TtsStatus
ttsAudioUrl: string | null
ttsError: string | null
presetVoices: PresetVoiceDisplay[]
onClose: () => void
onTextChange: (text: string) => void
onVoiceChange: (voiceId: string) => void
onSpeedChange: (speed: number) => void
onSynthesize: () => void
onSave: () => void
}
import { Modal } from "antd"
import { type TtsModalProps, type TtsStatus } from "./tts-modal/types"
import TextInputSection from "./tts-modal/TextInputSection"
import VoiceSelector from "./tts-modal/VoiceSelector"
import SpeedControl from "./tts-modal/SpeedControl"
import SynthesizeButton from "./tts-modal/SynthesizeButton"
import ErrorAlert from "./tts-modal/ErrorAlert"
import ResultPanel from "./tts-modal/ResultPanel"
/** AI 配音弹窗 */
const TtsModal: React.FC<TtsModalProps> = ({
@@ -50,205 +35,13 @@ const TtsModal: React.FC<TtsModalProps> = ({
padding: "8px 0",
}}
>
{/* 文本输入 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={ttsText}
onChange={(e) => onTextChange(e.target.value)}
placeholder="输入要配音的文本内容..."
maxLength={2000}
rows={4}
style={{
width: "100%",
padding: "10px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
lineHeight: 1.6,
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary)",
textAlign: "right",
marginTop: 4,
}}
>
{ttsText.length}/2000
</div>
</div>
{/* 音色选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<select
value={ttsVoiceId}
onChange={(e) => onVoiceChange(e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {genderLabel(v.gender)}
</option>
))}
</select>
</div>
{/* 语速 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
{ttsSpeed.toFixed(1)}x
</div>
<input
type="range"
min={0.5}
max={2.0}
step={0.1}
value={ttsSpeed}
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "var(--primary-color)" }}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--text-tertiary)",
}}
>
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
{/* 合成按钮 */}
<button
type="button"
onClick={() => {
if (!ttsText.trim()) {
message.warning("请输入要合成的文本")
return
}
onSynthesize()
}}
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
style={{
width: "100%",
padding: "10px 0",
borderRadius: 8,
border: "none",
background:
ttsStatus === "synthesizing" || !ttsText.trim()
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor: ttsStatus === "synthesizing" || !ttsText.trim() ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<RobotOutlined />
{ttsStatus === "synthesizing" ? "合成中..." : "开始合成"}
</button>
{/* 错误提示 */}
{ttsError && (
<div
style={{
padding: "10px 12px",
background: "var(--error-soft, #fff2f0)",
borderRadius: 8,
color: "var(--error-color, #ff4d4f)",
fontSize: 13,
}}
>
{ttsError}
</div>
)}
{/* 合成结果 */}
<TextInputSection value={ttsText} onChange={onTextChange} />
<VoiceSelector value={ttsVoiceId} onChange={onVoiceChange} presetVoices={presetVoices} />
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
{ttsError && <ErrorAlert error={ttsError} />}
{ttsStatus === "done" && ttsAudioUrl && (
<div
style={{
padding: 12,
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--success-color, #52c41a)",
}}
>
</div>
<audio controls src={ttsAudioUrl} style={{ width: "100%" }} />
<button
type="button"
onClick={onSave}
style={{
padding: "8px 0",
borderRadius: 8,
border: "1px solid var(--primary-color)",
background: "var(--primary-soft)",
color: "var(--primary-color)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}}
>
</button>
</div>
<ResultPanel audioUrl={ttsAudioUrl} onSave={onSave} />
)}
</div>
</Modal>
@@ -256,3 +49,5 @@ const TtsModal: React.FC<TtsModalProps> = ({
}
export default TtsModal
export type { TtsModalProps, TtsStatus }
@@ -0,0 +1,24 @@
import React from "react"
interface ErrorAlertProps {
error: string
}
/** 错误提示 */
const ErrorAlert: React.FC<ErrorAlertProps> = ({ error }) => {
return (
<div
style={{
padding: "10px 12px",
background: "var(--error-soft, #fff2f0)",
borderRadius: 8,
color: "var(--error-color, #ff4d4f)",
fontSize: 13,
}}
>
{error}
</div>
)
}
export default ErrorAlert
@@ -0,0 +1,51 @@
import React from "react"
interface ResultPanelProps {
audioUrl: string
onSave: () => void
}
/** 合成结果展示 */
const ResultPanel: React.FC<ResultPanelProps> = ({ audioUrl, onSave }) => {
return (
<div
style={{
padding: 12,
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--success-color, #52c41a)",
}}
>
</div>
<audio controls src={audioUrl} style={{ width: "100%" }} />
<button
type="button"
onClick={onSave}
style={{
padding: "8px 0",
borderRadius: 8,
border: "1px solid var(--primary-color)",
background: "var(--primary-soft)",
color: "var(--primary-color)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}}
>
</button>
</div>
)
}
export default ResultPanel
@@ -0,0 +1,47 @@
import React from "react"
import { TTS_CONFIG } from "./types"
interface SpeedControlProps {
speed: number
onChange: (speed: number) => void
}
/** 语速调节滑块 */
const SpeedControl: React.FC<SpeedControlProps> = ({ speed, onChange }) => {
return (
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
{speed.toFixed(1)}x
</div>
<input
type="range"
min={TTS_CONFIG.MIN_SPEED}
max={TTS_CONFIG.MAX_SPEED}
step={TTS_CONFIG.SPEED_STEP}
value={speed}
onChange={(e) => onChange(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "var(--primary-color)" }}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--text-tertiary)",
}}
>
<span>{TTS_CONFIG.MIN_SPEED}x</span>
<span>{TTS_CONFIG.DEFAULT_SPEED}x</span>
<span>{TTS_CONFIG.MAX_SPEED}x</span>
</div>
</div>
)
}
export default SpeedControl
@@ -0,0 +1,51 @@
import React from "react"
import { RobotOutlined } from "@ant-design/icons"
import { message } from "antd"
import { type TtsStatus } from "./types"
interface SynthesizeButtonProps {
status: TtsStatus
text: string
onClick: () => void
}
/** 合成按钮 */
const SynthesizeButton: React.FC<SynthesizeButtonProps> = ({ status, text, onClick }) => {
const disabled = status === "synthesizing" || !text.trim()
const handleClick = () => {
if (!text.trim()) {
message.warning("请输入要合成的文本")
return
}
onClick()
}
return (
<button
type="button"
onClick={handleClick}
disabled={disabled}
style={{
width: "100%",
padding: "10px 0",
borderRadius: 8,
border: "none",
background: disabled ? "var(--text-tertiary)" : "var(--primary-color)",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor: disabled ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<RobotOutlined />
{status === "synthesizing" ? "合成中..." : "开始合成"}
</button>
)
}
export default SynthesizeButton
@@ -0,0 +1,56 @@
import React from "react"
import { TTS_CONFIG } from "./types"
interface TextInputSectionProps {
value: string
onChange: (text: string) => void
}
/** 文本输入区 */
const TextInputSection: React.FC<TextInputSectionProps> = ({ value, onChange }) => {
return (
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="输入要配音的文本内容..."
maxLength={TTS_CONFIG.MAX_TEXT_LENGTH}
rows={4}
style={{
width: "100%",
padding: "10px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
lineHeight: 1.6,
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary)",
textAlign: "right",
marginTop: 4,
}}
>
{value.length}/{TTS_CONFIG.MAX_TEXT_LENGTH}
</div>
</div>
)
}
export default TextInputSection
@@ -0,0 +1,49 @@
import React from "react"
import { type PresetVoiceDisplay } from "@/pages/voices/types"
import { genderLabel } from "@/pages/voices/utils/format"
interface VoiceSelectorProps {
value: string
onChange: (voiceId: string) => void
presetVoices: PresetVoiceDisplay[]
}
/** 音色选择下拉 */
const VoiceSelector: React.FC<VoiceSelectorProps> = ({ value, onChange, presetVoices }) => {
return (
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {genderLabel(v.gender)}
</option>
))}
</select>
</div>
)
}
export default VoiceSelector
+8
View File
@@ -0,0 +1,8 @@
export { default } from "../TtsModal"
export * from "./types"
export { default as TextInputSection } from "./TextInputSection"
export { default as VoiceSelector } from "./VoiceSelector"
export { default as SpeedControl } from "./SpeedControl"
export { default as SynthesizeButton } from "./SynthesizeButton"
export { default as ErrorAlert } from "./ErrorAlert"
export { default as ResultPanel } from "./ResultPanel"
+29
View File
@@ -0,0 +1,29 @@
import { type PresetVoiceDisplay } from "@/pages/voices/types"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
export interface TtsModalProps {
open: boolean
ttsText: string
ttsVoiceId: string
ttsSpeed: number
ttsStatus: TtsStatus
ttsAudioUrl: string | null
ttsError: string | null
presetVoices: PresetVoiceDisplay[]
onClose: () => void
onTextChange: (text: string) => void
onVoiceChange: (voiceId: string) => void
onSpeedChange: (speed: number) => void
onSynthesize: () => void
onSave: () => void
}
/** TTS 常量配置 */
export const TTS_CONFIG = {
MAX_TEXT_LENGTH: 2000,
DEFAULT_SPEED: 1.0,
MIN_SPEED: 0.5,
MAX_SPEED: 2.0,
SPEED_STEP: 0.1,
} as const
@@ -21,6 +21,13 @@ import "@/pages/voices/components/upload-voice-modal/FormFields"
import "@/pages/voices/components/upload-voice-modal/ActionButtons"
import "@/pages/voices/components/upload-voice-modal/types"
import "@/pages/voices/components/TtsModal"
import "@/pages/voices/components/tts-modal/TextInputSection"
import "@/pages/voices/components/tts-modal/VoiceSelector"
import "@/pages/voices/components/tts-modal/SpeedControl"
import "@/pages/voices/components/tts-modal/SynthesizeButton"
import "@/pages/voices/components/tts-modal/ErrorAlert"
import "@/pages/voices/components/tts-modal/ResultPanel"
import "@/pages/voices/components/tts-modal/types"
import "@/pages/voices/components/VoiceFilterBar"
import "@/pages/voices/components/MaterialVoiceCard"