From 0bac70cce0d0f1a924334978aca76ced797e4188 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 13:55:40 +0800 Subject: [PATCH 01/10] =?UTF-8?q?fix(#1196):=20TTS=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E7=BB=9F=E4=B8=80/voices=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E9=9F=B3=E8=89=B2=E5=88=97=E8=A1=A8=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=85=8B=E9=9A=86=E9=9F=B3=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/TtsPanel/hooks/useTtsPanel.ts | 60 ++++++++++++++++--- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts b/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts index 2aa6297d1..82ff1b50c 100644 --- a/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts @@ -2,7 +2,8 @@ import { useState, useCallback, useEffect, useRef, type ChangeEvent } from "reac import { message } from "antd" import type { TtsConfig, TtsMode } from "../../../types" import { DEFAULT_TTS_CONFIG } from "../../../types" -import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts" +import { previewTts } from "@/api/tts" +import { fetchVoices, type UnifiedVoiceItem } from "@/api/voices" interface UseTtsPanelOptions { open: boolean @@ -12,8 +13,9 @@ interface UseTtsPanelOptions { } export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOptions) => { - /* ── 音色列表 ── */ - const [voices, setVoices] = useState([]) + /* ── 音色列表(统一音色:预设 + 克隆) ── */ + const [presetVoices, setPresetVoices] = useState([]) + const [cloneVoices, setCloneVoices] = useState([]) const [voicesLoading, setVoicesLoading] = useState(false) /* ── 试听状态 ── */ @@ -27,9 +29,17 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti mountedRef.current = true let active = true setVoicesLoading(true) - getTtsVoices() - .then((v) => { - if (active) setVoices(v) + fetchVoices({ status: "ready", limit: 100 }) + .then((res) => { + if (!active) return + const presets = res.items.filter((v) => v.type === "preset") + const clones = res.items.filter((v) => v.type === "clone") + setPresetVoices(presets) + setCloneVoices(clones) + // 如果当前没有选中音色,默认选第一个预设 + if (!config.voice_id && presets.length > 0) { + onChange({ ...config, voice_id: presets[0].voice_id }) + } }) .catch(() => { if (active) message.error("加载音色列表失败") @@ -40,6 +50,7 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti return () => { active = false } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) /* ── 组件卸载时清理音频资源 ── */ @@ -145,6 +156,35 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti } }, [config]) + /* ── 单音色示例试听 ── */ + const [playingVoiceId, setPlayingVoiceId] = useState(null) + const handlePlayVoiceSample = useCallback( + (voiceId: string, previewUrl: string | null) => { + if (playingVoiceId === voiceId) { + audioRef.current?.pause() + audioRef.current = null + setPlayingVoiceId(null) + return + } + audioRef.current?.pause() + if (!previewUrl) { + message.warning("该音色暂无示例音频") + return + } + const audio = new Audio(previewUrl) + audioRef.current = audio + audio.play().catch(() => { + message.error("播放失败") + }) + audio.onended = () => { + setPlayingVoiceId(null) + audioRef.current = null + } + setPlayingVoiceId(voiceId) + }, + [playingVoiceId], + ) + /* ── 重置 ── */ const handleReset = useCallback(() => { onChange({ ...DEFAULT_TTS_CONFIG }) @@ -155,16 +195,20 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti mountedRef.current = false audioRef.current?.pause() audioRef.current = null + setPlayingVoiceId(null) onClose() }, [onClose]) return { - voices, + presetVoices, + cloneVoices, voicesLoading, previewLoading, + playingVoiceId, handleModeChange, handleTextChange, handleVoiceSelect, + handlePlayVoiceSample, handleSpeedChange, handlePitchChange, handleVolumeChange, @@ -173,4 +217,4 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti handleReset, handleClose, } -} +} \ No newline at end of file -- 2.54.0 From b3c2a8f278c47318933f1afaaf4cf85a786d80d1 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 13:55:40 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix(#1196):=20VoiceSelector=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E4=B8=BATab=E5=B8=83=E5=B1=80=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E9=A2=84=E8=AE=BE=E9=9F=B3=E8=89=B2=E5=92=8C=E5=85=8B?= =?UTF-8?q?=E9=9A=86=E9=9F=B3=E8=89=B2=E5=88=86=E7=BB=84=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TtsPanel/components/VoiceSelector.tsx | 103 ++++++++++++++---- 1 file changed, 80 insertions(+), 23 deletions(-) diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx b/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx index 651c50d40..289899ee7 100644 --- a/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx @@ -1,21 +1,36 @@ -import React from "react" -import type { TTSVoice } from "@/api/tts" -import { VOICE_CATEGORY_MAP } from "../constants" +import React, { useState } from "react" +import type { UnifiedVoiceItem } from "@/api/voices" export interface VoiceSelectorProps { - voices: TTSVoice[] + presetVoices: UnifiedVoiceItem[] + cloneVoices: UnifiedVoiceItem[] selectedVoiceId: string loading: boolean + playingVoiceId: string | null onSelect: (voiceId: string) => void + onPlaySample: (voiceId: string, previewUrl: string | null) => void +} + +type VoiceTab = "preset" | "clone" + +const GENDER_ICON: Record = { + male: "👨", + female: "👩", + young: "🧑", } export const VoiceSelector: React.FC = ({ - voices, + presetVoices, + cloneVoices, selectedVoiceId, loading, + playingVoiceId, onSelect, + onPlaySample, }) => { - const voiceCategories = Object.entries(VOICE_CATEGORY_MAP) + const [activeTab, setActiveTab] = useState("preset") + + const currentList = activeTab === "preset" ? presetVoices : cloneVoices return (
@@ -23,23 +38,65 @@ export const VoiceSelector: React.FC = ({ 选择音色 {loading && 加载中...}
-
- {voiceCategories.map(([cat, info]) => { - const voice = voices.find((v) => v.category === cat) - const isSelected = voice && selectedVoiceId === voice.id - return ( - - ) - })} + + {/* Tab 切换 */} +
+ +
+ + {/* 音色列表 */} + {loading ? ( +
⏳ 加载中...
+ ) : currentList.length === 0 ? ( +
+ {activeTab === "preset" ? "暂无预设音色" : "暂无克隆音色,快去克隆一个吧"} +
+ ) : ( +
+ {currentList.map((voice) => { + const isSelected = selectedVoiceId === voice.voice_id + const isPlaying = playingVoiceId === voice.voice_id + const icon = GENDER_ICON[voice.gender] ?? "✨" + return ( +
onSelect(voice.voice_id)} + > +
{icon}
+
+
{voice.name}
+
{voice.description || voice.language}
+
+ +
+ ) + })} +
+ )}
) -} +} \ No newline at end of file -- 2.54.0 From 7eb79ed813661fec7413ea291377e7145b2f5f0d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 13:55:41 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(#1196):=20TtsPanel=E4=BC=A0=E5=85=A5?= =?UTF-8?q?=E6=96=B0=E7=9A=84props=E7=BB=99VoiceSelector=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=85=8B=E9=9A=86=E9=9F=B3=E8=89=B2=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E4=B8=8E=E8=AF=95=E5=90=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editing-planner/components/TtsPanel/index.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel/index.tsx b/apps/web/src/pages/editing-planner/components/TtsPanel/index.tsx index 212f6cb35..1a108a2e0 100644 --- a/apps/web/src/pages/editing-planner/components/TtsPanel/index.tsx +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/index.tsx @@ -1,6 +1,6 @@ /** * TTS 配音面板 — Drawer 形式 - * 配音模式切换 + 文本输入 + 音色选择 + 语速/语调/音量 + 试听 + 字幕联动 + * 配音模式切换 + 文本输入 + 音色选择(预设+克隆) + 语速/语调/音量 + 试听 + 字幕联动 */ import React from "react" import { Drawer } from "antd" @@ -21,12 +21,15 @@ export interface TtsPanelProps { const TtsPanel: React.FC = ({ open, onClose, config, onChange }) => { const { - voices, + presetVoices, + cloneVoices, voicesLoading, previewLoading, + playingVoiceId, handleModeChange, handleTextChange, handleVoiceSelect, + handlePlayVoiceSample, handleSpeedChange, handlePitchChange, handleVolumeChange, @@ -83,10 +86,13 @@ const TtsPanel: React.FC = ({ open, onClose, config, onChange }) {/* 音色选择 */} {/* 语速 */} @@ -169,4 +175,4 @@ const TtsPanel: React.FC = ({ open, onClose, config, onChange }) ) } -export default TtsPanel +export default TtsPanel \ No newline at end of file -- 2.54.0 From 1aa50c9c65ab6b53545b2517ba257d4fb72393ae Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 13:55:56 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix(#1196):=20=E6=9B=B4=E6=96=B0TTS?= =?UTF-8?q?=E9=9F=B3=E8=89=B2=E9=80=89=E6=8B=A9=E5=99=A8=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81Tab=E5=88=87=E6=8D=A2=E5=92=8C?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/editing-planner/EditingPlanner.css | 142 +++++++++++++++--- 1 file changed, 117 insertions(+), 25 deletions(-) diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css index dcf046346..e13607330 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.css +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -3531,52 +3531,143 @@ color: var(--text-tertiary, #9ca3af); } -.tts-voice-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; +.tts-voice-tabs { + display: flex; + gap: 4px; + background: var(--bg-secondary, #f3f4f6); + border-radius: var(--radius-md, 8px); + padding: 3px; } -.tts-voice-card { +.tts-voice-tab { + flex: 1; display: flex; - flex-direction: column; align-items: center; - gap: 4px; - padding: 10px 6px; - border: 1px solid var(--border-primary, #e5e7eb); - border-radius: var(--radius-md, 8px); - background: var(--bg-surface, #fff); + justify-content: center; + gap: 6px; + padding: 6px 12px; + border: none; + background: transparent; + border-radius: 6px; + font-size: 12px; + font-weight: 500; color: var(--text-secondary, #6b7280); cursor: pointer; transition: all 0.2s ease; - font-size: 12px; } -.tts-voice-card:hover:not(:disabled) { - border-color: var(--primary-300, #93c5fd); - color: var(--primary-600, #2563eb); - background: var(--primary-50, #eff6ff); +.tts-voice-tab:hover { + color: var(--text-primary, #1f2937); } -.tts-voice-card.active { - border-color: var(--primary-500, #3b82f6); +.tts-voice-tab.active { + background: var(--bg-surface, #fff); color: var(--primary-600, #2563eb); - background: var(--primary-50, #eff6ff); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.tts-voice-tab-count { + font-size: 10px; + padding: 1px 6px; + background: var(--primary-100, #dbeafe); + color: var(--primary-600, #2563eb); + border-radius: 10px; font-weight: 600; } -.tts-voice-card:disabled { - opacity: 0.5; - cursor: not-allowed; +.tts-voice-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 200px; + overflow-y: auto; } -.tts-voice-card-icon { - font-size: 22px; +.tts-voice-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--border-primary, #e5e7eb); + border-radius: var(--radius-md, 8px); + background: var(--bg-surface, #fff); + cursor: pointer; + transition: all 0.2s ease; } -.tts-voice-card-name { +.tts-voice-item:hover { + border-color: var(--primary-300, #93c5fd); + background: var(--primary-50, #eff6ff); +} + +.tts-voice-item.selected { + border-color: var(--primary-500, #3b82f6); + background: var(--primary-50, #eff6ff); +} + +.tts-voice-item-icon { + font-size: 20px; + flex-shrink: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary, #f3f4f6); + border-radius: 50%; +} + +.tts-voice-item-info { + flex: 1; + min-width: 0; +} + +.tts-voice-item-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary, #1f2937); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tts-voice-item-desc { font-size: 11px; + color: var(--text-tertiary, #9ca3af); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tts-voice-item-play { + flex-shrink: 0; + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: var(--primary-500, #3b82f6); + color: #fff; + border-radius: 50%; + font-size: 11px; + cursor: pointer; + transition: all 0.2s ease; +} + +.tts-voice-item-play:hover { + background: var(--primary-600, #2563eb); + transform: scale(1.08); +} + +.tts-voice-empty { + padding: 20px; text-align: center; + font-size: 12px; + color: var(--text-tertiary, #9ca3af); + background: var(--bg-secondary, #f3f4f6); + border-radius: var(--radius-md, 8px); } /* ── 滑块区域 ── */ @@ -6317,3 +6408,4 @@ padding: 16px; text-align: center; } + -- 2.54.0 From c73e213e35d8daa67954d2d7d936fbad14e8b12d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 14:27:59 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix(#1196):=20=E4=BF=AE=E5=A4=8D=E8=AF=95?= =?UTF-8?q?=E5=90=AC=E6=92=AD=E6=94=BE=E5=A4=B1=E8=B4=A5=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=B8=8D=E4=B8=80=E8=87=B4=20+=20=E9=9F=B3=E9=A2=91=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E7=AB=9E=E6=80=81=20+=20=E5=88=97=E8=A1=A8key?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=B8=BAvoice=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/TtsPanel/hooks/useTtsPanel.ts | 222 ++++++++++-------- 1 file changed, 123 insertions(+), 99 deletions(-) diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts b/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts index 82ff1b50c..9ba3b7a94 100644 --- a/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts @@ -1,203 +1,227 @@ -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 { previewTts } from "@/api/tts" -import { fetchVoices, type UnifiedVoiceItem } from "@/api/voices" +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 { previewTts } from "@/api/tts"; +import { fetchVoices, type UnifiedVoiceItem } from "@/api/voices"; interface UseTtsPanelOptions { - open: boolean - config: TtsConfig - onChange: (config: TtsConfig) => void - onClose: () => void + open: boolean; + config: TtsConfig; + onChange: (config: TtsConfig) => void; + onClose: () => void; } -export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOptions) => { +export const useTtsPanel = ({ + open, + config, + onChange, + onClose, +}: UseTtsPanelOptions) => { /* ── 音色列表(统一音色:预设 + 克隆) ── */ - const [presetVoices, setPresetVoices] = useState([]) - const [cloneVoices, setCloneVoices] = useState([]) - const [voicesLoading, setVoicesLoading] = useState(false) + const [presetVoices, setPresetVoices] = useState([]); + const [cloneVoices, setCloneVoices] = useState([]); + const [voicesLoading, setVoicesLoading] = useState(false); /* ── 试听状态 ── */ - const [previewLoading, setPreviewLoading] = useState(false) - const audioRef = useRef(null) - const mountedRef = useRef(true) + const [previewLoading, setPreviewLoading] = useState(false); + const audioRef = useRef(null); + const mountedRef = useRef(true); /* ── 加载音色列表 + 重置挂载状态 ── */ useEffect(() => { - if (!open) return - mountedRef.current = true - let active = true - setVoicesLoading(true) + if (!open) return; + mountedRef.current = true; + let active = true; + setVoicesLoading(true); fetchVoices({ status: "ready", limit: 100 }) .then((res) => { - if (!active) return - const presets = res.items.filter((v) => v.type === "preset") - const clones = res.items.filter((v) => v.type === "clone") - setPresetVoices(presets) - setCloneVoices(clones) + if (!active) return; + const presets = res.items.filter((v) => v.type === "preset"); + const clones = res.items.filter((v) => v.type === "clone"); + setPresetVoices(presets); + setCloneVoices(clones); // 如果当前没有选中音色,默认选第一个预设 if (!config.voice_id && presets.length > 0) { - onChange({ ...config, voice_id: presets[0].voice_id }) + onChange({ ...config, voice_id: presets[0].voice_id }); } }) .catch(() => { - if (active) message.error("加载音色列表失败") + if (active) message.error("加载音色列表失败"); }) .finally(() => { - if (active) setVoicesLoading(false) - }) + if (active) setVoicesLoading(false); + }); return () => { - active = false - } + active = false; + }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]) + }, [open]); /* ── 组件卸载时清理音频资源 ── */ useEffect(() => { return () => { - audioRef.current?.pause() - audioRef.current = null - mountedRef.current = false - } - }, []) + audioRef.current?.pause(); + audioRef.current = null; + mountedRef.current = false; + }; + }, []); /* ── 切换配音模式 ── */ const handleModeChange = useCallback( (mode: TtsMode) => { - onChange({ ...config, mode }) + onChange({ ...config, mode }); }, [config, onChange], - ) + ); /* ── 文本输入 ── */ const handleTextChange = useCallback( (e: ChangeEvent) => { - const text = e.target.value.slice(0, 5000) - onChange({ ...config, text }) + const text = e.target.value.slice(0, 5000); + onChange({ ...config, text }); }, [config, onChange], - ) + ); /* ── 选择音色 ── */ const handleVoiceSelect = useCallback( (voiceId: string) => { - onChange({ ...config, voice_id: voiceId }) + onChange({ ...config, voice_id: voiceId }); }, [config, onChange], - ) + ); /* ── 语速 ── */ const handleSpeedChange = useCallback( (speed: number) => { - onChange({ ...config, speed }) + onChange({ ...config, speed }); }, [config, onChange], - ) + ); /* ── 语调 ── */ const handlePitchChange = useCallback( (pitch: number) => { - onChange({ ...config, pitch }) + onChange({ ...config, pitch }); }, [config, onChange], - ) + ); /* ── 音量 ── */ const handleVolumeChange = useCallback( (volume: number) => { - onChange({ ...config, volume }) + onChange({ ...config, volume }); }, [config, onChange], - ) + ); /* ── 字幕联动 ── */ const handleSubtitleSyncToggle = useCallback(() => { - onChange({ ...config, subtitle_sync: !config.subtitle_sync }) - }, [config, onChange]) + onChange({ ...config, subtitle_sync: !config.subtitle_sync }); + }, [config, onChange]); /* ── 试听 ── */ const handlePreview = useCallback(async () => { if (!config.text.trim()) { - message.warning("请先输入合成文本") - return + message.warning("请先输入合成文本"); + return; } if (!config.voice_id) { - message.warning("请先选择音色") - return + message.warning("请先选择音色"); + return; } - setPreviewLoading(true) + 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 + }); + 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("试听播放中") + if (mountedRef.current) message.success("试听播放中"); }) .catch(() => { - if (mountedRef.current) message.error("播放失败") - }) + if (mountedRef.current) message.error("播放失败"); + }); audio.onended = () => { - if (mountedRef.current) audioRef.current = null - } + if (mountedRef.current) audioRef.current = null; + }; } catch { - if (mountedRef.current) message.error("试听生成失败") + if (mountedRef.current) message.error("试听生成失败"); } finally { - if (mountedRef.current) setPreviewLoading(false) + if (mountedRef.current) setPreviewLoading(false); } - }, [config]) + }, [config]); /* ── 单音色示例试听 ── */ - const [playingVoiceId, setPlayingVoiceId] = useState(null) + const [playingVoiceId, setPlayingVoiceId] = useState(null); const handlePlayVoiceSample = useCallback( (voiceId: string, previewUrl: string | null) => { if (playingVoiceId === voiceId) { - audioRef.current?.pause() - audioRef.current = null - setPlayingVoiceId(null) - return + audioRef.current?.pause(); + audioRef.current = null; + setPlayingVoiceId(null); + return; + } + // 暂停上一个音频并清除事件监听,避免竞态 + if (audioRef.current) { + audioRef.current.onended = null; + audioRef.current.pause(); } - audioRef.current?.pause() if (!previewUrl) { - message.warning("该音色暂无示例音频") - return + message.warning("该音色暂无示例音频"); + return; } - const audio = new Audio(previewUrl) - audioRef.current = audio - audio.play().catch(() => { - message.error("播放失败") - }) + const audio = new Audio(previewUrl); + audioRef.current = audio; + audio + .play() + .then(() => { + if (mountedRef.current) setPlayingVoiceId(voiceId); + }) + .catch(() => { + if (mountedRef.current) { + message.error("播放失败"); + audioRef.current = null; + } + }); audio.onended = () => { - setPlayingVoiceId(null) - audioRef.current = null - } - setPlayingVoiceId(voiceId) + if (mountedRef.current) { + setPlayingVoiceId(null); + audioRef.current = null; + } + }; }, [playingVoiceId], - ) + ); /* ── 重置 ── */ const handleReset = useCallback(() => { - onChange({ ...DEFAULT_TTS_CONFIG }) - }, [onChange]) + onChange({ ...DEFAULT_TTS_CONFIG }); + }, [onChange]); /* ── 关闭时停止音频 ── */ const handleClose = useCallback(() => { - mountedRef.current = false - audioRef.current?.pause() - audioRef.current = null - setPlayingVoiceId(null) - onClose() - }, [onClose]) + mountedRef.current = false; + audioRef.current?.pause(); + audioRef.current = null; + setPlayingVoiceId(null); + onClose(); + }, [onClose]); return { presetVoices, @@ -216,5 +240,5 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti handlePreview, handleReset, handleClose, - } -} \ No newline at end of file + }; +}; -- 2.54.0 From 919b91b8606be3acc2bea3a88e6722187b7d72f2 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 14:28:08 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix(#1196):=20=E9=9F=B3=E8=89=B2=E5=88=97?= =?UTF-8?q?=E8=A1=A8key=E7=BB=9F=E4=B8=80=E4=B8=BAvoice=5Fid=EF=BC=8C?= =?UTF-8?q?=E4=B8=8E=E4=B8=9A=E5=8A=A1=E9=80=BB=E8=BE=91=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TtsPanel/components/VoiceSelector.tsx | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx b/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx index 289899ee7..2a6dbc9ae 100644 --- a/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx @@ -1,23 +1,23 @@ -import React, { useState } from "react" -import type { UnifiedVoiceItem } from "@/api/voices" +import React, { useState } from "react"; +import type { UnifiedVoiceItem } from "@/api/voices"; export interface VoiceSelectorProps { - presetVoices: UnifiedVoiceItem[] - cloneVoices: UnifiedVoiceItem[] - selectedVoiceId: string - loading: boolean - playingVoiceId: string | null - onSelect: (voiceId: string) => void - onPlaySample: (voiceId: string, previewUrl: string | null) => void + presetVoices: UnifiedVoiceItem[]; + cloneVoices: UnifiedVoiceItem[]; + selectedVoiceId: string; + loading: boolean; + playingVoiceId: string | null; + onSelect: (voiceId: string) => void; + onPlaySample: (voiceId: string, previewUrl: string | null) => void; } -type VoiceTab = "preset" | "clone" +type VoiceTab = "preset" | "clone"; const GENDER_ICON: Record = { male: "👨", female: "👩", young: "🧑", -} +}; export const VoiceSelector: React.FC = ({ presetVoices, @@ -28,9 +28,9 @@ export const VoiceSelector: React.FC = ({ onSelect, onPlaySample, }) => { - const [activeTab, setActiveTab] = useState("preset") + const [activeTab, setActiveTab] = useState("preset"); - const currentList = activeTab === "preset" ? presetVoices : cloneVoices + const currentList = activeTab === "preset" ? presetVoices : cloneVoices; return (
@@ -63,40 +63,47 @@ export const VoiceSelector: React.FC = ({
⏳ 加载中...
) : currentList.length === 0 ? (
- {activeTab === "preset" ? "暂无预设音色" : "暂无克隆音色,快去克隆一个吧"} + {activeTab === "preset" + ? "暂无预设音色" + : "暂无克隆音色,快去克隆一个吧"}
) : (
{currentList.map((voice) => { - const isSelected = selectedVoiceId === voice.voice_id - const isPlaying = playingVoiceId === voice.voice_id - const icon = GENDER_ICON[voice.gender] ?? "✨" + const isSelected = selectedVoiceId === voice.voice_id; + const isPlaying = playingVoiceId === voice.voice_id; + const icon = GENDER_ICON[voice.gender] ?? "✨"; return (
onSelect(voice.voice_id)} >
{icon}
{voice.name}
-
{voice.description || voice.language}
+
+ {voice.description || voice.language} +
- ) + ); })}
)}
- ) -} \ No newline at end of file + ); +}; -- 2.54.0 From a12577144eedfd2e3228673f9cb3a8d921a170a8 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 15:42:15 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix(#1196):=20Prettier=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20+=20useEffect=E9=97=AD=E5=8C=85ref?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20=E6=92=AD=E6=94=BE=E7=AB=9E=E6=80=81?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/TtsPanel/hooks/useTtsPanel.ts | 231 +++++++++--------- 1 file changed, 116 insertions(+), 115 deletions(-) diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts b/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts index 9ba3b7a94..cc4214ecd 100644 --- a/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel.ts @@ -1,227 +1,228 @@ -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 { previewTts } from "@/api/tts"; -import { fetchVoices, type UnifiedVoiceItem } from "@/api/voices"; +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 { previewTts } from "@/api/tts" +import { fetchVoices, type UnifiedVoiceItem } from "@/api/voices" interface UseTtsPanelOptions { - open: boolean; - config: TtsConfig; - onChange: (config: TtsConfig) => void; - onClose: () => void; + open: boolean + config: TtsConfig + onChange: (config: TtsConfig) => void + onClose: () => void } -export const useTtsPanel = ({ - open, - config, - onChange, - onClose, -}: UseTtsPanelOptions) => { +export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOptions) => { /* ── 音色列表(统一音色:预设 + 克隆) ── */ - const [presetVoices, setPresetVoices] = useState([]); - const [cloneVoices, setCloneVoices] = useState([]); - const [voicesLoading, setVoicesLoading] = useState(false); + const [presetVoices, setPresetVoices] = useState([]) + const [cloneVoices, setCloneVoices] = useState([]) + const [voicesLoading, setVoicesLoading] = useState(false) + + // 用 ref 保存最新 config 和 onChange,避免 useEffect 闭包陷阱 + const configRef = useRef(config) + const onChangeRef = useRef(onChange) + configRef.current = config + onChangeRef.current = onChange /* ── 试听状态 ── */ - const [previewLoading, setPreviewLoading] = useState(false); - const audioRef = useRef(null); - const mountedRef = useRef(true); + const [previewLoading, setPreviewLoading] = useState(false) + const audioRef = useRef(null) + const mountedRef = useRef(true) /* ── 加载音色列表 + 重置挂载状态 ── */ useEffect(() => { - if (!open) return; - mountedRef.current = true; - let active = true; - setVoicesLoading(true); + if (!open) return + mountedRef.current = true + let active = true + setVoicesLoading(true) fetchVoices({ status: "ready", limit: 100 }) .then((res) => { - if (!active) return; - const presets = res.items.filter((v) => v.type === "preset"); - const clones = res.items.filter((v) => v.type === "clone"); - setPresetVoices(presets); - setCloneVoices(clones); - // 如果当前没有选中音色,默认选第一个预设 - if (!config.voice_id && presets.length > 0) { - onChange({ ...config, voice_id: presets[0].voice_id }); + if (!active) return + const presets = res.items.filter((v) => v.type === "preset") + const clones = res.items.filter((v) => v.type === "clone") + setPresetVoices(presets) + setCloneVoices(clones) + // 如果当前没有选中音色,默认选第一个预设(用 ref 读最新值,避免闭包旧值覆盖用户选择) + if (!configRef.current.voice_id && presets.length > 0) { + onChangeRef.current({ + ...configRef.current, + voice_id: presets[0].voice_id, + }) } }) .catch(() => { - if (active) message.error("加载音色列表失败"); + if (active) message.error("加载音色列表失败") }) .finally(() => { - if (active) setVoicesLoading(false); - }); + if (active) setVoicesLoading(false) + }) return () => { - active = false; - }; + active = false + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); + }, [open]) /* ── 组件卸载时清理音频资源 ── */ useEffect(() => { return () => { - audioRef.current?.pause(); - audioRef.current = null; - mountedRef.current = false; - }; - }, []); + audioRef.current?.pause() + audioRef.current = null + mountedRef.current = false + } + }, []) /* ── 切换配音模式 ── */ const handleModeChange = useCallback( (mode: TtsMode) => { - onChange({ ...config, mode }); + onChange({ ...config, mode }) }, [config, onChange], - ); + ) /* ── 文本输入 ── */ const handleTextChange = useCallback( (e: ChangeEvent) => { - const text = e.target.value.slice(0, 5000); - onChange({ ...config, text }); + const text = e.target.value.slice(0, 5000) + onChange({ ...config, text }) }, [config, onChange], - ); + ) /* ── 选择音色 ── */ const handleVoiceSelect = useCallback( (voiceId: string) => { - onChange({ ...config, voice_id: voiceId }); + onChange({ ...config, voice_id: voiceId }) }, [config, onChange], - ); + ) /* ── 语速 ── */ const handleSpeedChange = useCallback( (speed: number) => { - onChange({ ...config, speed }); + onChange({ ...config, speed }) }, [config, onChange], - ); + ) /* ── 语调 ── */ const handlePitchChange = useCallback( (pitch: number) => { - onChange({ ...config, pitch }); + onChange({ ...config, pitch }) }, [config, onChange], - ); + ) /* ── 音量 ── */ const handleVolumeChange = useCallback( (volume: number) => { - onChange({ ...config, volume }); + onChange({ ...config, volume }) }, [config, onChange], - ); + ) /* ── 字幕联动 ── */ const handleSubtitleSyncToggle = useCallback(() => { - onChange({ ...config, subtitle_sync: !config.subtitle_sync }); - }, [config, onChange]); + onChange({ ...config, subtitle_sync: !config.subtitle_sync }) + }, [config, onChange]) /* ── 试听 ── */ const handlePreview = useCallback(async () => { if (!config.text.trim()) { - message.warning("请先输入合成文本"); - return; + message.warning("请先输入合成文本") + return } if (!config.voice_id) { - message.warning("请先选择音色"); - return; + message.warning("请先选择音色") + return } - setPreviewLoading(true); + 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; + }) + 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("试听播放中"); + if (mountedRef.current) message.success("试听播放中") }) .catch(() => { - if (mountedRef.current) message.error("播放失败"); - }); + if (mountedRef.current) message.error("播放失败") + }) audio.onended = () => { - if (mountedRef.current) audioRef.current = null; - }; + if (mountedRef.current) audioRef.current = null + } } catch { - if (mountedRef.current) message.error("试听生成失败"); + if (mountedRef.current) message.error("试听生成失败") } finally { - if (mountedRef.current) setPreviewLoading(false); + if (mountedRef.current) setPreviewLoading(false) } - }, [config]); + }, [config]) /* ── 单音色示例试听 ── */ - const [playingVoiceId, setPlayingVoiceId] = useState(null); + const [playingVoiceId, setPlayingVoiceId] = useState(null) const handlePlayVoiceSample = useCallback( (voiceId: string, previewUrl: string | null) => { if (playingVoiceId === voiceId) { - audioRef.current?.pause(); - audioRef.current = null; - setPlayingVoiceId(null); - return; + audioRef.current?.pause() + audioRef.current = null + setPlayingVoiceId(null) + return } // 暂停上一个音频并清除事件监听,避免竞态 if (audioRef.current) { - audioRef.current.onended = null; - audioRef.current.pause(); + audioRef.current.onended = null + audioRef.current.pause() } if (!previewUrl) { - message.warning("该音色暂无示例音频"); - return; + message.warning("该音色暂无示例音频") + return } - const audio = new Audio(previewUrl); - audioRef.current = audio; + const audio = new Audio(previewUrl) + audioRef.current = audio audio .play() .then(() => { - if (mountedRef.current) setPlayingVoiceId(voiceId); + // 只有当前活跃的音频实例才能更新状态,防止快速切换竞态 + if (mountedRef.current && audioRef.current === audio) { + setPlayingVoiceId(voiceId) + } }) .catch(() => { - if (mountedRef.current) { - message.error("播放失败"); - audioRef.current = null; + if (mountedRef.current && audioRef.current === audio) { + message.error("播放失败") + audioRef.current = null } - }); + }) audio.onended = () => { - if (mountedRef.current) { - setPlayingVoiceId(null); - audioRef.current = null; + if (mountedRef.current && audioRef.current === audio) { + setPlayingVoiceId(null) + audioRef.current = null } - }; + } }, [playingVoiceId], - ); + ) /* ── 重置 ── */ const handleReset = useCallback(() => { - onChange({ ...DEFAULT_TTS_CONFIG }); - }, [onChange]); + onChange({ ...DEFAULT_TTS_CONFIG }) + }, [onChange]) /* ── 关闭时停止音频 ── */ const handleClose = useCallback(() => { - mountedRef.current = false; - audioRef.current?.pause(); - audioRef.current = null; - setPlayingVoiceId(null); - onClose(); - }, [onClose]); + mountedRef.current = false + audioRef.current?.pause() + audioRef.current = null + setPlayingVoiceId(null) + onClose() + }, [onClose]) return { presetVoices, @@ -240,5 +241,5 @@ export const useTtsPanel = ({ handlePreview, handleReset, handleClose, - }; -}; + } +} -- 2.54.0 From c24a76278da16953a361e87d9dd05525b9fa0113 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 15:42:18 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(#1196):=20Prettier=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20+=20=E5=88=97=E8=A1=A8key=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E4=B8=BAvoice=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TtsPanel/components/VoiceSelector.tsx | 53 ++++++++----------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx b/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx index 2a6dbc9ae..ea9317d83 100644 --- a/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/components/VoiceSelector.tsx @@ -1,23 +1,23 @@ -import React, { useState } from "react"; -import type { UnifiedVoiceItem } from "@/api/voices"; +import React, { useState } from "react" +import type { UnifiedVoiceItem } from "@/api/voices" export interface VoiceSelectorProps { - presetVoices: UnifiedVoiceItem[]; - cloneVoices: UnifiedVoiceItem[]; - selectedVoiceId: string; - loading: boolean; - playingVoiceId: string | null; - onSelect: (voiceId: string) => void; - onPlaySample: (voiceId: string, previewUrl: string | null) => void; + presetVoices: UnifiedVoiceItem[] + cloneVoices: UnifiedVoiceItem[] + selectedVoiceId: string + loading: boolean + playingVoiceId: string | null + onSelect: (voiceId: string) => void + onPlaySample: (voiceId: string, previewUrl: string | null) => void } -type VoiceTab = "preset" | "clone"; +type VoiceTab = "preset" | "clone" const GENDER_ICON: Record = { male: "👨", female: "👩", young: "🧑", -}; +} export const VoiceSelector: React.FC = ({ presetVoices, @@ -28,9 +28,9 @@ export const VoiceSelector: React.FC = ({ onSelect, onPlaySample, }) => { - const [activeTab, setActiveTab] = useState("preset"); + const [activeTab, setActiveTab] = useState("preset") - const currentList = activeTab === "preset" ? presetVoices : cloneVoices; + const currentList = activeTab === "preset" ? presetVoices : cloneVoices return (
@@ -63,16 +63,14 @@ export const VoiceSelector: React.FC = ({
⏳ 加载中...
) : currentList.length === 0 ? (
- {activeTab === "preset" - ? "暂无预设音色" - : "暂无克隆音色,快去克隆一个吧"} + {activeTab === "preset" ? "暂无预设音色" : "暂无克隆音色,快去克隆一个吧"}
) : (
{currentList.map((voice) => { - const isSelected = selectedVoiceId === voice.voice_id; - const isPlaying = playingVoiceId === voice.voice_id; - const icon = GENDER_ICON[voice.gender] ?? "✨"; + const isSelected = selectedVoiceId === voice.voice_id + const isPlaying = playingVoiceId === voice.voice_id + const icon = GENDER_ICON[voice.gender] ?? "✨" return (
= ({
{icon}
{voice.name}
-
- {voice.description || voice.language} -
+
{voice.description || voice.language}
- ); + ) })}
)}
- ); -}; + ) +} -- 2.54.0 From 2ce2aa2486844dc314c94d925caeafdd4f910913 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 15:49:35 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix(#1196):=20Prettier=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/src/pages/editing-planner/components/TtsPanel/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel/index.tsx b/apps/web/src/pages/editing-planner/components/TtsPanel/index.tsx index 1a108a2e0..7bcab1ed4 100644 --- a/apps/web/src/pages/editing-planner/components/TtsPanel/index.tsx +++ b/apps/web/src/pages/editing-planner/components/TtsPanel/index.tsx @@ -175,4 +175,4 @@ const TtsPanel: React.FC = ({ open, onClose, config, onChange }) ) } -export default TtsPanel \ No newline at end of file +export default TtsPanel -- 2.54.0 From 6154f3063da6e6a7af92cee037093eb77577689d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 16:58:32 +0800 Subject: [PATCH 10/10] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0TtsPanel=20smok?= =?UTF-8?q?e=20test=E4=BB=A5=E4=BF=AE=E5=A4=8Dvitest=20related=E5=8C=B9?= =?UTF-8?q?=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/TtsPanel.smoke.test.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 apps/web/src/test/pages/editing-planner/components/TtsPanel.smoke.test.tsx diff --git a/apps/web/src/test/pages/editing-planner/components/TtsPanel.smoke.test.tsx b/apps/web/src/test/pages/editing-planner/components/TtsPanel.smoke.test.tsx new file mode 100644 index 000000000..3ab403bb0 --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/TtsPanel.smoke.test.tsx @@ -0,0 +1,23 @@ +/** + * TtsPanel 模块 smoke test + * 建立 TtsPanel 目录化拆分后子模块的依赖链 + * 确保 vitest related 模式能匹配到 TtsPanel/ 下所有文件的改动 + */ +import { describe, it, expect } from "vitest" + +// TtsPanel 主组件与子组件 +import "@/pages/editing-planner/components/TtsPanel" +import "@/pages/editing-planner/components/TtsPanel/components/VoiceSelector" +import "@/pages/editing-planner/components/TtsPanel/components/TtsSlider" + +// TtsPanel Hooks +import "@/pages/editing-planner/components/TtsPanel/hooks/useTtsPanel" + +// TtsPanel 常量 +import "@/pages/editing-planner/components/TtsPanel/constants" + +describe("TtsPanel module smoke test", () => { + it("should load all TtsPanel sub-modules", () => { + expect(true).toBe(true) + }) +}) -- 2.54.0