From 09f214d9414ea9a4e4e32c9c91946ceb282accca Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 14:05:00 +0800 Subject: [PATCH 1/9] =?UTF-8?q?fix(#1195):=20CloneVoiceSection=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=AF=95=E5=90=AC=E6=92=AD=E6=94=BE=E6=8C=89=E9=92=AE?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=85=8B=E9=9A=86=E9=9F=B3=E8=89=B2?= =?UTF-8?q?=E8=AF=95=E5=90=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/voice/CloneVoiceSection.tsx | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/generate/components/voice/CloneVoiceSection.tsx b/apps/web/src/pages/generate/components/voice/CloneVoiceSection.tsx index 37947529d..761bafce5 100644 --- a/apps/web/src/pages/generate/components/voice/CloneVoiceSection.tsx +++ b/apps/web/src/pages/generate/components/voice/CloneVoiceSection.tsx @@ -1,10 +1,18 @@ /** * 克隆声音展开区域 * 克隆按钮、轮询提示、已克隆列表、空状态 + * 支持克隆音色试听播放 + * TODO: 后端克隆音色试听接口出来后,替换 sample_url 为接口返回的试听音频 */ import React from "react" import { Typography } from "antd" -import { ThunderboltOutlined, AudioOutlined, CheckCircleFilled } from "@ant-design/icons" +import { + ThunderboltOutlined, + AudioOutlined, + CheckCircleFilled, + PlayCircleOutlined, + PauseCircleOutlined, +} from "@ant-design/icons" import type { VoiceClone } from "@/api/voice-clone" const { Text } = Typography @@ -15,6 +23,10 @@ interface CloneVoiceSectionProps { selectedClonedVoice: string onSelect: (voiceId: string) => void onOpenCloneModal: () => void + /** 当前正在播放的音色ID */ + playingVoiceId: string | null + /** 播放/暂停试听 */ + onPlaySample: (voiceId: VoiceClone) => void CLONE_STATUS_CONFIG: Record formatDuration: (seconds: number) => string } @@ -25,6 +37,8 @@ const CloneVoiceSection: React.FC = ({ selectedClonedVoice, onSelect, onOpenCloneModal, + playingVoiceId, + onPlaySample, CLONE_STATUS_CONFIG, formatDuration, }) => { @@ -60,6 +74,7 @@ const CloneVoiceSection: React.FC = ({ const statusCfg = CLONE_STATUS_CONFIG[cv.status] const isReady = cv.status === "ready" const selected = selectedClonedVoice === cv.id + const isPlaying = playingVoiceId === cv.id return (
= ({ )}
- {selected && isReady && ( + + {/* 试听播放按钮 */} + {isReady && ( + + )} + + {selected && isReady && !isPlaying && ( )} -- 2.54.0 From 950dced40be79f2aecffec63f082f6b1f8e986a4 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 14:05:02 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix(#1195):=20useStep5Voice=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=85=8B=E9=9A=86=E9=9F=B3=E8=89=B2=E8=AF=95=E5=90=AC?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E9=80=BB=E8=BE=91=EF=BC=8C=E9=A2=84=E7=95=99?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=8E=A5=E5=8F=A3=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/generate/hooks/useStep5Voice.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx index ca630c3a3..6256826b4 100755 --- a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx +++ b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx @@ -2,6 +2,7 @@ * Step 5 配音选择 Hook * 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑 */ +import { useState, useRef, useCallback } from "react" import { message } from "antd" import type { VoiceClone } from "@/api/voice-clone" import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants" @@ -100,6 +101,40 @@ export function useStep5Voice({ onCloneModalOpenChange, }) + /* ── 克隆音色试听播放 ── */ + const [playingCloneVoice, setPlayingCloneVoice] = useState(null) + const cloneAudioRef = useRef(null) + + const handlePlayCloneSample = useCallback( + (voice: VoiceClone) => { + if (playingCloneVoice === voice.id) { + cloneAudioRef.current?.pause() + cloneAudioRef.current = null + setPlayingCloneVoice(null) + return + } + cloneAudioRef.current?.pause() + // TODO: 后端克隆音色试听接口出来后,改为调用接口获取试听音频 + // 目前使用 sample_url 字段,如果没有则提示 + const audioUrl = voice.sample_url + if (!audioUrl) { + message.warning("该克隆音色暂无试听音频") + return + } + const audio = new Audio(audioUrl) + cloneAudioRef.current = audio + audio.play().catch(() => { + message.error("播放失败,请检查网络") + }) + audio.onended = () => { + setPlayingCloneVoice(null) + cloneAudioRef.current = null + } + setPlayingCloneVoice(voice.id) + }, + [playingCloneVoice], + ) + const handleCloneSuccess = (voice: VoiceClone) => { rawCloneSuccess(voice) message.success("音色克隆成功!") @@ -124,6 +159,9 @@ export function useStep5Voice({ // 音频播放 playingVoice, toggleVoicePlay, + // 克隆音色试听播放 + playingCloneVoice, + handlePlayCloneSample, // 预设音色操作 handleSelectPresetVoice, handleSelectCloneVoice, @@ -163,3 +201,4 @@ export function useStep5Voice({ } export default useStep5Voice + -- 2.54.0 From c434fc264147d6ad92522899e03a8942585508a4 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 14:05:04 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix(#1195):=20Step5VoiceSelect=E4=BC=A0?= =?UTF-8?q?=E9=80=92=E6=92=AD=E6=94=BE=E7=8A=B6=E6=80=81=E5=92=8C=E6=96=B9?= =?UTF-8?q?=E6=B3=95=E7=BB=99CloneVoiceSection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/generate/components/Step5VoiceSelect.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx b/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx index 740dd7546..d38152693 100644 --- a/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx +++ b/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx @@ -126,6 +126,8 @@ const Step5VoiceSelect: React.FC = (props) => { selectedClonedVoice={v.selectedClonedVoice} onSelect={v.handleSelectClonedVoice} onOpenCloneModal={v.handleOpenCloneModal} + playingVoiceId={v.playingCloneVoice} + onPlaySample={v.handlePlayCloneSample} CLONE_STATUS_CONFIG={v.CLONE_STATUS_CONFIG} formatDuration={v.formatDuration} /> @@ -135,3 +137,4 @@ const Step5VoiceSelect: React.FC = (props) => { } export default Step5VoiceSelect + -- 2.54.0 From 3e3ebf71ce535bec34b50a067b30271551f35f98 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 14:05:12 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix(#1195):=20=E5=A2=9E=E5=8A=A0=E5=85=8B?= =?UTF-8?q?=E9=9A=86=E9=9F=B3=E8=89=B2=E8=AF=95=E5=90=AC=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/generate/generate.css | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 2c2f0702c..4006261e1 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -558,6 +558,32 @@ color: var(--text-primary); } +/* ── 克隆音色试听播放按钮 ── */ +.xx-clone-voice-play-btn { + flex-shrink: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: var(--primary-color); + color: #fff; + border-radius: 50%; + cursor: pointer; + transition: all 0.2s ease; + margin-right: 4px; +} + +.xx-clone-voice-play-btn:hover { + transform: scale(1.1); + background: var(--primary-hover); +} + +.xx-clone-voice-row.selected .xx-clone-voice-play-btn { + margin-right: 8px; +} + .xx-clone-status { font-size: 11px; display: flex; @@ -2639,3 +2665,4 @@ .ant-modal-close { color: #fff !important; } + -- 2.54.0 From a19be2e304bb73e0f4e5edc6bcce04e711b6eb2c Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 14:30:25 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix(#1195):=20=E5=85=8B=E9=9A=86=E9=9F=B3?= =?UTF-8?q?=E8=89=B2=E6=92=AD=E6=94=BE=E5=A4=B1=E8=B4=A5=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20=E9=9F=B3=E9=A2=91=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E7=AB=9E=E6=80=81=E9=98=B2=E6=8A=A4=20+=20=E5=8D=B8=E8=BD=BD?= =?UTF-8?q?=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/generate/hooks/useStep5Voice.tsx | 137 +++++++++++------- 1 file changed, 84 insertions(+), 53 deletions(-) diff --git a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx index 6256826b4..89fbe68e1 100755 --- a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx +++ b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx @@ -2,30 +2,30 @@ * Step 5 配音选择 Hook * 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑 */ -import { useState, useRef, useCallback } from "react" -import { message } from "antd" -import type { VoiceClone } from "@/api/voice-clone" -import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants" -import { formatDuration } from "../utils/formatDuration" -import { useVoiceAudio } from "./step5-voice/useVoiceAudio" -import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend" -import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis" -import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary" -import { useVoiceModeSelection } from "./step5-voice/useVoiceModeSelection" +import { useState, useRef, useCallback, useEffect } from "react"; +import { message } from "antd"; +import type { VoiceClone } from "@/api/voice-clone"; +import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"; +import { formatDuration } from "../utils/formatDuration"; +import { useVoiceAudio } from "./step5-voice/useVoiceAudio"; +import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend"; +import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis"; +import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary"; +import { useVoiceModeSelection } from "./step5-voice/useVoiceModeSelection"; interface UseStep5VoiceProps { - selectedVoice: string - onSelectedVoiceChange: (voiceId: string) => void - voiceMode: "preset" | "custom" | "clone" - onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void - selectedClonedVoice: string - onSelectedClonedVoiceChange: (voiceId: string) => void - clonedVoices: VoiceClone[] - addClone: (voice: VoiceClone) => void - hasProcessing: boolean - cloneModalOpen: boolean - onCloneModalOpenChange: (open: boolean) => void - titleText: string + selectedVoice: string; + onSelectedVoiceChange: (voiceId: string) => void; + voiceMode: "preset" | "custom" | "clone"; + onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void; + selectedClonedVoice: string; + onSelectedClonedVoiceChange: (voiceId: string) => void; + clonedVoices: VoiceClone[]; + addClone: (voice: VoiceClone) => void; + hasProcessing: boolean; + cloneModalOpen: boolean; + onCloneModalOpenChange: (open: boolean) => void; + titleText: string; } export function useStep5Voice({ @@ -43,7 +43,7 @@ export function useStep5Voice({ titleText, }: UseStep5VoiceProps) { /* ── 子模块 ── */ - const { playingVoice, toggleVoicePlay } = useVoiceAudio() + const { playingVoice, toggleVoicePlay } = useVoiceAudio(); const { presetVoices, @@ -52,7 +52,7 @@ export function useStep5Voice({ voiceRecommendations, hasVoiceRecommend, handleVoiceRecommend, - } = useVoiceRecommend(titleText) + } = useVoiceRecommend(titleText); const { customVoiceText, @@ -64,7 +64,7 @@ export function useStep5Voice({ synthesizeMutation, handleSynthesizeVoice, resetTtsState, - } = useTtsSynthesis(selectedVoice) + } = useTtsSynthesis(selectedVoice); const { saveModalOpen, @@ -80,7 +80,7 @@ export function useStep5Voice({ handleOpenSaveModal, handleConfirmSave, handleAddTagInModal, - } = useSaveToLibrary(completedTtsJobId, resetTtsState) + } = useSaveToLibrary(completedTtsJobId, resetTtsState); const { handleSelectRecommendedVoice, @@ -99,46 +99,78 @@ export function useStep5Voice({ addClone, cloneModalOpen, onCloneModalOpenChange, - }) + }); /* ── 克隆音色试听播放 ── */ - const [playingCloneVoice, setPlayingCloneVoice] = useState(null) - const cloneAudioRef = useRef(null) + const [playingCloneVoice, setPlayingCloneVoice] = useState( + null, + ); + const cloneAudioRef = useRef(null); + const cloneMountedRef = useRef(true); + + // 组件卸载时清理克隆音色试听的音频资源 + useEffect(() => { + cloneMountedRef.current = true; + return () => { + cloneMountedRef.current = false; + if (cloneAudioRef.current) { + cloneAudioRef.current.onended = null; + cloneAudioRef.current.pause(); + cloneAudioRef.current = null; + } + }; + }, []); const handlePlayCloneSample = useCallback( (voice: VoiceClone) => { if (playingCloneVoice === voice.id) { - cloneAudioRef.current?.pause() - cloneAudioRef.current = null - setPlayingCloneVoice(null) - return + if (cloneAudioRef.current) { + cloneAudioRef.current.onended = null; + cloneAudioRef.current.pause(); + } + cloneAudioRef.current = null; + setPlayingCloneVoice(null); + return; + } + // 暂停上一个音频并清除事件监听,避免竞态 + if (cloneAudioRef.current) { + cloneAudioRef.current.onended = null; + cloneAudioRef.current.pause(); } - cloneAudioRef.current?.pause() // TODO: 后端克隆音色试听接口出来后,改为调用接口获取试听音频 // 目前使用 sample_url 字段,如果没有则提示 - const audioUrl = voice.sample_url + const audioUrl = voice.sample_url; if (!audioUrl) { - message.warning("该克隆音色暂无试听音频") - return + message.warning("该克隆音色暂无试听音频"); + return; } - const audio = new Audio(audioUrl) - cloneAudioRef.current = audio - audio.play().catch(() => { - message.error("播放失败,请检查网络") - }) + const audio = new Audio(audioUrl); + cloneAudioRef.current = audio; + audio + .play() + .then(() => { + if (cloneMountedRef.current) setPlayingCloneVoice(voice.id); + }) + .catch(() => { + if (cloneMountedRef.current) { + message.error("播放失败,请检查网络"); + cloneAudioRef.current = null; + } + }); audio.onended = () => { - setPlayingCloneVoice(null) - cloneAudioRef.current = null - } - setPlayingCloneVoice(voice.id) + if (cloneMountedRef.current) { + setPlayingCloneVoice(null); + cloneAudioRef.current = null; + } + }; }, [playingCloneVoice], - ) + ); const handleCloneSuccess = (voice: VoiceClone) => { - rawCloneSuccess(voice) - message.success("音色克隆成功!") - } + rawCloneSuccess(voice); + message.success("音色克隆成功!"); + }; return { // 数据 @@ -197,8 +229,7 @@ export function useStep5Voice({ VOICE_GENDER_ICON, CLONE_STATUS_CONFIG, formatDuration, - } + }; } -export default useStep5Voice - +export default useStep5Voice; -- 2.54.0 From 71ca97f03a4b2b1998c205b081b312494f2cdad4 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 15:34:53 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix(#1195):=20=E9=9F=B3=E9=A2=91=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E7=AB=9E=E6=80=81=E4=BF=AE=E5=A4=8D=20-=20=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=E4=B8=AD=E6=A0=A1=E9=AA=8C=E5=BD=93=E5=89=8D=E6=B4=BB?= =?UTF-8?q?=E8=B7=83=E9=9F=B3=E9=A2=91=E5=AE=9E=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/generate/hooks/useStep5Voice.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx index 89fbe68e1..27fa35269 100755 --- a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx +++ b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx @@ -149,16 +149,19 @@ export function useStep5Voice({ audio .play() .then(() => { - if (cloneMountedRef.current) setPlayingCloneVoice(voice.id); + // 只有当前活跃的音频实例才能更新状态,防止快速切换竞态 + if (cloneMountedRef.current && cloneAudioRef.current === audio) { + setPlayingCloneVoice(voice.id); + } }) .catch(() => { - if (cloneMountedRef.current) { + if (cloneMountedRef.current && cloneAudioRef.current === audio) { message.error("播放失败,请检查网络"); cloneAudioRef.current = null; } }); audio.onended = () => { - if (cloneMountedRef.current) { + if (cloneMountedRef.current && cloneAudioRef.current === audio) { setPlayingCloneVoice(null); cloneAudioRef.current = null; } -- 2.54.0 From db213d1c35df892d6a5f6068da3e705aca489b5a Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 15:41:40 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix(#1195):=20Prettier=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20+=20=E9=9F=B3=E9=A2=91=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E7=AB=9E=E6=80=81=E6=A0=A1=E9=AA=8C=20+=20=E5=8D=B8=E8=BD=BD?= =?UTF-8?q?=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/generate/hooks/useStep5Voice.tsx | 126 +++++++++--------- 1 file changed, 62 insertions(+), 64 deletions(-) diff --git a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx index 27fa35269..3ee459c9b 100755 --- a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx +++ b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx @@ -2,30 +2,30 @@ * Step 5 配音选择 Hook * 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑 */ -import { useState, useRef, useCallback, useEffect } from "react"; -import { message } from "antd"; -import type { VoiceClone } from "@/api/voice-clone"; -import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"; -import { formatDuration } from "../utils/formatDuration"; -import { useVoiceAudio } from "./step5-voice/useVoiceAudio"; -import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend"; -import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis"; -import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary"; -import { useVoiceModeSelection } from "./step5-voice/useVoiceModeSelection"; +import { useState, useRef, useCallback, useEffect } from "react" +import { message } from "antd" +import type { VoiceClone } from "@/api/voice-clone" +import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants" +import { formatDuration } from "../utils/formatDuration" +import { useVoiceAudio } from "./step5-voice/useVoiceAudio" +import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend" +import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis" +import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary" +import { useVoiceModeSelection } from "./step5-voice/useVoiceModeSelection" interface UseStep5VoiceProps { - selectedVoice: string; - onSelectedVoiceChange: (voiceId: string) => void; - voiceMode: "preset" | "custom" | "clone"; - onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void; - selectedClonedVoice: string; - onSelectedClonedVoiceChange: (voiceId: string) => void; - clonedVoices: VoiceClone[]; - addClone: (voice: VoiceClone) => void; - hasProcessing: boolean; - cloneModalOpen: boolean; - onCloneModalOpenChange: (open: boolean) => void; - titleText: string; + selectedVoice: string + onSelectedVoiceChange: (voiceId: string) => void + voiceMode: "preset" | "custom" | "clone" + onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void + selectedClonedVoice: string + onSelectedClonedVoiceChange: (voiceId: string) => void + clonedVoices: VoiceClone[] + addClone: (voice: VoiceClone) => void + hasProcessing: boolean + cloneModalOpen: boolean + onCloneModalOpenChange: (open: boolean) => void + titleText: string } export function useStep5Voice({ @@ -43,7 +43,7 @@ export function useStep5Voice({ titleText, }: UseStep5VoiceProps) { /* ── 子模块 ── */ - const { playingVoice, toggleVoicePlay } = useVoiceAudio(); + const { playingVoice, toggleVoicePlay } = useVoiceAudio() const { presetVoices, @@ -52,7 +52,7 @@ export function useStep5Voice({ voiceRecommendations, hasVoiceRecommend, handleVoiceRecommend, - } = useVoiceRecommend(titleText); + } = useVoiceRecommend(titleText) const { customVoiceText, @@ -64,7 +64,7 @@ export function useStep5Voice({ synthesizeMutation, handleSynthesizeVoice, resetTtsState, - } = useTtsSynthesis(selectedVoice); + } = useTtsSynthesis(selectedVoice) const { saveModalOpen, @@ -80,7 +80,7 @@ export function useStep5Voice({ handleOpenSaveModal, handleConfirmSave, handleAddTagInModal, - } = useSaveToLibrary(completedTtsJobId, resetTtsState); + } = useSaveToLibrary(completedTtsJobId, resetTtsState) const { handleSelectRecommendedVoice, @@ -99,81 +99,79 @@ export function useStep5Voice({ addClone, cloneModalOpen, onCloneModalOpenChange, - }); + }) /* ── 克隆音色试听播放 ── */ - const [playingCloneVoice, setPlayingCloneVoice] = useState( - null, - ); - const cloneAudioRef = useRef(null); - const cloneMountedRef = useRef(true); + const [playingCloneVoice, setPlayingCloneVoice] = useState(null) + const cloneAudioRef = useRef(null) + const cloneMountedRef = useRef(true) // 组件卸载时清理克隆音色试听的音频资源 useEffect(() => { - cloneMountedRef.current = true; + cloneMountedRef.current = true return () => { - cloneMountedRef.current = false; + cloneMountedRef.current = false if (cloneAudioRef.current) { - cloneAudioRef.current.onended = null; - cloneAudioRef.current.pause(); - cloneAudioRef.current = null; + cloneAudioRef.current.onended = null + cloneAudioRef.current.pause() + cloneAudioRef.current = null } - }; - }, []); + } + }, []) const handlePlayCloneSample = useCallback( (voice: VoiceClone) => { if (playingCloneVoice === voice.id) { if (cloneAudioRef.current) { - cloneAudioRef.current.onended = null; - cloneAudioRef.current.pause(); + cloneAudioRef.current.onended = null + cloneAudioRef.current.pause() } - cloneAudioRef.current = null; - setPlayingCloneVoice(null); - return; + cloneAudioRef.current = null + setPlayingCloneVoice(null) + return } // 暂停上一个音频并清除事件监听,避免竞态 if (cloneAudioRef.current) { - cloneAudioRef.current.onended = null; - cloneAudioRef.current.pause(); + cloneAudioRef.current.onended = null + cloneAudioRef.current.pause() } // TODO: 后端克隆音色试听接口出来后,改为调用接口获取试听音频 // 目前使用 sample_url 字段,如果没有则提示 - const audioUrl = voice.sample_url; + const audioUrl = voice.sample_url if (!audioUrl) { - message.warning("该克隆音色暂无试听音频"); - return; + message.warning("该克隆音色暂无试听音频") + return } - const audio = new Audio(audioUrl); - cloneAudioRef.current = audio; + const audio = new Audio(audioUrl) + cloneAudioRef.current = audio audio .play() .then(() => { // 只有当前活跃的音频实例才能更新状态,防止快速切换竞态 if (cloneMountedRef.current && cloneAudioRef.current === audio) { - setPlayingCloneVoice(voice.id); + setPlayingCloneVoice(voice.id) } }) .catch(() => { if (cloneMountedRef.current && cloneAudioRef.current === audio) { - message.error("播放失败,请检查网络"); - cloneAudioRef.current = null; + message.error("播放失败,请检查网络") + cloneAudioRef.current = null } - }); + }) audio.onended = () => { if (cloneMountedRef.current && cloneAudioRef.current === audio) { - setPlayingCloneVoice(null); - cloneAudioRef.current = null; + setPlayingCloneVoice(null) + cloneAudioRef.current = null } - }; + } }, [playingCloneVoice], - ); + ) const handleCloneSuccess = (voice: VoiceClone) => { - rawCloneSuccess(voice); - message.success("音色克隆成功!"); - }; + rawCloneSuccess(voice) + message.success("音色克隆成功!") + } return { // 数据 @@ -232,7 +230,7 @@ export function useStep5Voice({ VOICE_GENDER_ICON, CLONE_STATUS_CONFIG, formatDuration, - }; + } } -export default useStep5Voice; +export default useStep5Voice -- 2.54.0 From c206ca04d690a83d6fa6c03dcce2e63072cedc6b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 15:41:54 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix(#1195):=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 --- apps/web/src/pages/generate/components/Step5VoiceSelect.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx b/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx index d38152693..1de7b049e 100644 --- a/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx +++ b/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx @@ -137,4 +137,3 @@ const Step5VoiceSelect: React.FC = (props) => { } export default Step5VoiceSelect - -- 2.54.0 From 599f52403c0e59ce211fdb466916e520ca104eea Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 30 Jul 2026 16:04:40 +0800 Subject: [PATCH 9/9] =?UTF-8?q?test(#1195):=20=E6=B7=BB=E5=8A=A0useStep5Vo?= =?UTF-8?q?ice=20smoke=20test=EF=BC=8C=E7=A1=AE=E4=BF=9Dvitest=20related?= =?UTF-8?q?=E8=83=BD=E5=85=B3=E8=81=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/generate/useStep5Voice.test.tsx | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 apps/web/src/test/pages/generate/useStep5Voice.test.tsx diff --git a/apps/web/src/test/pages/generate/useStep5Voice.test.tsx b/apps/web/src/test/pages/generate/useStep5Voice.test.tsx new file mode 100644 index 000000000..8fd9f9dc1 --- /dev/null +++ b/apps/web/src/test/pages/generate/useStep5Voice.test.tsx @@ -0,0 +1,97 @@ +/** + * useStep5Voice Hook smoke test + * 确保 vitest related 模式能匹配到 useStep5Voice.tsx 的改动 + */ +import { describe, it, expect, vi } from "vitest" +import { renderHook } from "@testing-library/react" + +// mock 所有子 Hook +vi.mock("@/pages/generate/hooks/step5-voice/useVoiceAudio", () => ({ + useVoiceAudio: () => ({ + playingVoice: null, + toggleVoicePlay: vi.fn(), + }), +})) + +vi.mock("@/pages/generate/hooks/step5-voice/useVoiceRecommend", () => ({ + useVoiceRecommend: () => ({ + presetVoices: [], + presetVoicesLoading: false, + voiceRecommendLoading: false, + voiceRecommendations: [], + hasVoiceRecommend: false, + handleVoiceRecommend: vi.fn(), + }), +})) + +vi.mock("@/pages/generate/hooks/step5-voice/useTtsSynthesis", () => ({ + useTtsSynthesis: () => ({ + customVoiceText: "", + setCustomVoiceText: vi.fn(), + customAudioUrl: null, + ttsError: null, + ttsJobId: null, + completedTtsJobId: null, + synthesizeMutation: { isPending: false, mutate: vi.fn() }, + handleSynthesizeVoice: vi.fn(), + resetTtsState: vi.fn(), + }), +})) + +vi.mock("@/pages/generate/hooks/step5-voice/useSaveToLibrary", () => ({ + useSaveToLibrary: () => ({ + saveModalOpen: false, + setSaveModalOpen: vi.fn(), + saveName: "", + setSaveName: vi.fn(), + saveTagIds: [], + setSaveTagIds: vi.fn(), + saveNewTag: "", + setSaveNewTag: vi.fn(), + allTags: [], + saveToLibraryMutation: { isPending: false, mutate: vi.fn() }, + handleOpenSaveModal: vi.fn(), + handleConfirmSave: vi.fn(), + handleAddTagInModal: vi.fn(), + }), +})) + +vi.mock("@/pages/generate/hooks/step5-voice/useVoiceModeSelection", () => ({ + useVoiceModeSelection: () => ({ + handleSelectRecommendedVoice: vi.fn(), + handleSelectPresetVoice: vi.fn(), + handleSelectCloneVoice: vi.fn(), + handleSelectClonedVoice: vi.fn(), + handleOpenCloneModal: vi.fn(), + handleCloneSuccess: vi.fn(), + }), +})) + +vi.mock("@/api/voice-clone", () => ({ + VoiceCloneStatus: { READY: "ready", PROCESSING: "processing", FAILED: "failed" }, +})) + +describe("useStep5Voice smoke test", () => { + it("should import and render hook without crashing", async () => { + // 动态 import 确保 mock 生效 + const { useStep5Voice } = await import("@/pages/generate/hooks/useStep5Voice") + const { result } = renderHook(() => + useStep5Voice({ + selectedVoice: "", + onSelectedVoiceChange: vi.fn(), + voiceMode: "preset", + onVoiceModeChange: vi.fn(), + selectedClonedVoice: "", + onSelectedClonedVoiceChange: vi.fn(), + clonedVoices: [], + addClone: vi.fn(), + hasProcessing: false, + cloneModalOpen: false, + onCloneModalOpenChange: vi.fn(), + titleText: "测试标题", + }), + ) + expect(result.current).toBeDefined() + expect(typeof result.current.handlePlayCloneSample).toBe("function") + }) +}) -- 2.54.0