Merge pull request 'fix(#1195): 我的音色页面试听按钮对接/voice-clones/{id}/preview接口' (#1204) from fix/voice-clone-preview-frontend-1195 into develop
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m38s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 53s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 42s
CI/CD Pipeline / Unit Tests (push) Successful in 8m49s
CI/CD Pipeline / Integration Tests (push) Successful in 2m54s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m14s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 17m43s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m38s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m17s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m37s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m52s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / CI Gate (push) Has been skipped

This commit was merged in pull request #1204.
This commit is contained in:
2026-08-01 16:20:34 +08:00
6 changed files with 79 additions and 11 deletions
+15
View File
@@ -11,6 +11,7 @@ import type {
VoiceCloneListParams,
ListVoiceCloneResponse,
VoiceCloneStatusResponse,
VoiceClonePreviewResponse,
} from "./types"
/** 获取克隆音色列表(返回前端兼容数组) */
@@ -85,3 +86,17 @@ export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> =>
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
return response.data
}
/** 获取克隆音色试听音频(实时 TTS 合成) */
export const getVoiceClonePreview = async (
cloneId: string,
text?: string,
): Promise<VoiceClonePreviewResponse> => {
const searchParams = new URLSearchParams()
if (text) searchParams.set("text", text)
const qs = searchParams.toString()
const response = await apiClient.get<VoiceClonePreviewResponse>(
`/voice-clones/${cloneId}/preview${qs ? `?${qs}` : ""}`,
)
return response.data
}
+2
View File
@@ -14,6 +14,7 @@ export type {
VoiceCloneStatusResponse,
CreateVoiceCloneRequestFull,
VoiceCloneListParams,
VoiceClonePreviewResponse,
} from "./types"
// 工具函数
@@ -29,4 +30,5 @@ export {
updateVoiceClone,
getVoiceCloneStatus,
retryVoiceClone,
getVoiceClonePreview,
} from "./clones"
+21
View File
@@ -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
/** 文件大小(字节) */
}
@@ -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}
@@ -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<VoiceCardProps> = ({
voice,
isPlaying,
isLoading = false,
onTogglePlay,
onEdit,
onDelete,
@@ -74,8 +77,13 @@ export const VoiceCard: React.FC<VoiceCardProps> = ({
buttonType={isPlaying ? "secondary" : "ghost"}
buttonSize="sm"
onClick={() => onTogglePlay(voice)}
disabled={isLoading}
>
{isPlaying ? (
{isLoading ? (
<>
<LoadingOutlined />
</>
) : isPlaying ? (
<>
<PauseCircleOutlined />
</>
@@ -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<string | null>(null)
const [loadingPreviewId, setLoadingPreviewId] = useState<string | null>(null)
const [toasts, setToasts] = useState<ToastItem[]>([])
const [editModalOpen, setEditModalOpen] = useState(false)
const [editingVoice, setEditingVoice] = useState<VoiceClone | null>(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,