refactor(voices): 拆分 TtsModal 为子组件(258→53行, -79%) #1051
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user