From 74f146bbaf3eedc702deff6b3c857ee83672781a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E7=AB=AFAgent?= Date: Fri, 31 Jul 2026 15:51:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(#1195):=20=E6=88=91=E7=9A=84=E9=9F=B3?= =?UTF-8?q?=E8=89=B2=E9=A1=B5=E9=9D=A2=E8=AF=95=E5=90=AC=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E5=AF=B9=E6=8E=A5/voice-clones/{id}/preview=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API层新增VoiceClonePreviewResponse类型和getVoiceClonePreview函数 - useMyVoices hook播放逻辑改为先调接口获取音频URL再播放 - 新增loadingPreviewId状态,试听合成中显示加载态 - VoiceCard增加isLoading prop,加载时按钮显示"合成中…"并禁用 - 错误处理:接口失败Toast提示,播放失败友好提示 --- apps/web/src/api/voice-clone/clones.ts | 15 +++++++ apps/web/src/api/voice-clone/index.ts | 2 + apps/web/src/api/voice-clone/types.ts | 21 ++++++++++ apps/web/src/pages/my-voices/MyVoices.tsx | 2 + .../pages/my-voices/components/VoiceCard.tsx | 10 ++++- .../src/pages/my-voices/hooks/useMyVoices.ts | 40 ++++++++++++++----- 6 files changed, 79 insertions(+), 11 deletions(-) diff --git a/apps/web/src/api/voice-clone/clones.ts b/apps/web/src/api/voice-clone/clones.ts index 45714b5fd..d4c4dfc79 100644 --- a/apps/web/src/api/voice-clone/clones.ts +++ b/apps/web/src/api/voice-clone/clones.ts @@ -11,6 +11,7 @@ import type { VoiceCloneListParams, ListVoiceCloneResponse, VoiceCloneStatusResponse, + VoiceClonePreviewResponse, } from "./types" /** 获取克隆音色列表(返回前端兼容数组) */ @@ -85,3 +86,17 @@ export const retryVoiceClone = async (id: string): Promise => const response = await apiClient.post(`/voice-clones/${id}/retry`) return response.data } + +/** 获取克隆音色试听音频(实时 TTS 合成) */ +export const getVoiceClonePreview = async ( + cloneId: string, + text?: string, +): Promise => { + const searchParams = new URLSearchParams() + if (text) searchParams.set("text", text) + const qs = searchParams.toString() + const response = await apiClient.get( + `/voice-clones/${cloneId}/preview${qs ? `?${qs}` : ""}`, + ) + return response.data +} diff --git a/apps/web/src/api/voice-clone/index.ts b/apps/web/src/api/voice-clone/index.ts index cd5e1b23c..ac59ed231 100644 --- a/apps/web/src/api/voice-clone/index.ts +++ b/apps/web/src/api/voice-clone/index.ts @@ -14,6 +14,7 @@ export type { VoiceCloneStatusResponse, CreateVoiceCloneRequestFull, VoiceCloneListParams, + VoiceClonePreviewResponse, } from "./types" // 工具函数 @@ -29,4 +30,5 @@ export { updateVoiceClone, getVoiceCloneStatus, retryVoiceClone, + getVoiceClonePreview, } from "./clones" diff --git a/apps/web/src/api/voice-clone/types.ts b/apps/web/src/api/voice-clone/types.ts index 56b780cfa..7cc9096a3 100644 --- a/apps/web/src/api/voice-clone/types.ts +++ b/apps/web/src/api/voice-clone/types.ts @@ -90,3 +90,24 @@ export interface VoiceCloneListParams { skip?: number limit?: number } + +/** 克隆音色试听响应 */ +export interface VoiceClonePreviewResponse { + clone_id: string + /** 音色克隆档案 ID */ + + voice_id: string + /** CosyVoice 音色 ID */ + + audio_url: string + /** 试听音频 URL */ + + text: string + /** 试听文本 */ + + duration: number + /** 音频时长(秒) */ + + file_size: number + /** 文件大小(字节) */ +} diff --git a/apps/web/src/pages/my-voices/MyVoices.tsx b/apps/web/src/pages/my-voices/MyVoices.tsx index 14b602981..90980f01a 100755 --- a/apps/web/src/pages/my-voices/MyVoices.tsx +++ b/apps/web/src/pages/my-voices/MyVoices.tsx @@ -26,6 +26,7 @@ const MyVoices: React.FC = () => { loading, hasProcessing, playingId, + loadingPreviewId, toasts, editModalOpen, editName, @@ -75,6 +76,7 @@ const MyVoices: React.FC = () => { key={voice.id} voice={voice} isPlaying={playingId === voice.id} + isLoading={loadingPreviewId === voice.id} onTogglePlay={handleTogglePlay} onEdit={handleEdit} onDelete={handleDelete} diff --git a/apps/web/src/pages/my-voices/components/VoiceCard.tsx b/apps/web/src/pages/my-voices/components/VoiceCard.tsx index 78e561075..b2a63aafa 100644 --- a/apps/web/src/pages/my-voices/components/VoiceCard.tsx +++ b/apps/web/src/pages/my-voices/components/VoiceCard.tsx @@ -6,6 +6,7 @@ import { EditOutlined, SoundOutlined, ClockCircleOutlined, + LoadingOutlined, } from "@ant-design/icons" import { Button, Tooltip } from "@/components/ui" import { formatDuration, type VoiceClone } from "@/api/voice-clone" @@ -15,6 +16,7 @@ import { formatDate } from "../utils" interface VoiceCardProps { voice: VoiceClone isPlaying: boolean + isLoading?: boolean onTogglePlay: (voice: VoiceClone) => void onEdit: (voice: VoiceClone) => void onDelete: (voice: VoiceClone) => void @@ -26,6 +28,7 @@ interface VoiceCardProps { export const VoiceCard: React.FC = ({ voice, isPlaying, + isLoading = false, onTogglePlay, onEdit, onDelete, @@ -74,8 +77,13 @@ export const VoiceCard: React.FC = ({ buttonType={isPlaying ? "secondary" : "ghost"} buttonSize="sm" onClick={() => onTogglePlay(voice)} + disabled={isLoading} > - {isPlaying ? ( + {isLoading ? ( + <> + 合成中… + + ) : isPlaying ? ( <> 暂停 diff --git a/apps/web/src/pages/my-voices/hooks/useMyVoices.ts b/apps/web/src/pages/my-voices/hooks/useMyVoices.ts index e45f17852..b8072c44b 100644 --- a/apps/web/src/pages/my-voices/hooks/useMyVoices.ts +++ b/apps/web/src/pages/my-voices/hooks/useMyVoices.ts @@ -1,7 +1,7 @@ import { useState, useCallback, useRef } from "react" import { useNavigate } from "react-router-dom" import { useCloneProgress } from "@/hooks/useCloneProgress" -import { deleteVoiceClone, updateVoiceClone } from "@/api/voice-clone" +import { deleteVoiceClone, updateVoiceClone, getVoiceClonePreview } from "@/api/voice-clone" import type { VoiceClone } from "@/api/voice-clone" import type { ToastItem } from "../types" @@ -15,6 +15,7 @@ export const useMyVoices = () => { const navigate = useNavigate() const { clones, loading, removeClone, updateClone, hasProcessing } = useCloneProgress() const [playingId, setPlayingId] = useState(null) + const [loadingPreviewId, setLoadingPreviewId] = useState(null) const [toasts, setToasts] = useState([]) const [editModalOpen, setEditModalOpen] = useState(false) const [editingVoice, setEditingVoice] = useState(null) @@ -31,26 +32,44 @@ export const useMyVoices = () => { // 试听播放 const handleTogglePlay = useCallback( - (voice: VoiceClone) => { + async (voice: VoiceClone) => { + // 正在播放同一个 → 暂停 if (playingId === voice.id) { audioRef.current?.pause() setPlayingId(null) return } + + // 先停掉当前播放 if (audioRef.current) { audioRef.current.pause() + audioRef.current = null } - if (!voice.sample_url) { - showToast("暂无试听音频", "error") + + // 如果正在加载其他试听,不重复触发 + if (loadingPreviewId && loadingPreviewId !== voice.id) { return } - const audio = new Audio(voice.sample_url) - audioRef.current = audio - audio.play().catch(() => showToast("播放失败,请检查音频文件", "error")) - audio.onended = () => setPlayingId(null) - setPlayingId(voice.id) + + // 调用试听接口获取音频 URL + setLoadingPreviewId(voice.id) + try { + const preview = await getVoiceClonePreview(voice.id) + const audio = new Audio(preview.audio_url) + audioRef.current = audio + audio.play().catch(() => { + showToast("播放失败,请检查音频文件", "error") + setPlayingId(null) + }) + audio.onended = () => setPlayingId(null) + setPlayingId(voice.id) + } catch { + showToast("试听音频生成失败,请重试", "error") + } finally { + setLoadingPreviewId(null) + } }, - [playingId, showToast], + [playingId, loadingPreviewId, showToast], ) // 编辑 @@ -117,6 +136,7 @@ export const useMyVoices = () => { hasProcessing, // 状态 playingId, + loadingPreviewId, toasts, editModalOpen, editingVoice,