diff --git a/apps/web/src/api/voiceClone.ts b/apps/web/src/api/voiceClone.ts index 11e6d9633..9e5ea7f0d 100644 --- a/apps/web/src/api/voiceClone.ts +++ b/apps/web/src/api/voiceClone.ts @@ -1,12 +1,15 @@ /** * 音色克隆 API - * Sprint 4 — mock 数据,后续迁移真实 API + * 任务 3.11:替换 Mock 数据,对接后端真实 API(3.05) */ +import apiClient from "./client"; -/** 克隆音色状态 */ +/* ── 前端兼容类型 ─────────────────────────────────────── */ + +/** 克隆音色状态(前端展示用) */ export type VoiceCloneStatus = "ready" | "processing" | "failed"; -/** 克隆音色条目 */ +/** 克隆音色条目(前端展示用) */ export interface VoiceClone { id: string; name: string; @@ -17,55 +20,76 @@ export interface VoiceClone { updated_at: string; } -/** 创建克隆请求 */ +/** 创建克隆请求(前端简化版) */ export interface CreateVoiceCloneRequest { name: string; audio_url: string; } -/* ── Mock 数据 ─────────────────────────────────────────── */ +/* ── 后端 API 类型 ────────────────────────────────────── */ -const MOCK_CLONES: VoiceClone[] = [ - { - id: "vc-001", - name: "我的播音腔", - duration_seconds: 128, - status: "ready", - sample_url: "", - created_at: "2026-06-20T10:30:00Z", - updated_at: "2026-06-20T10:35:00Z", - }, - { - id: "vc-002", - name: "温柔女声", - duration_seconds: 256, - status: "ready", - sample_url: "", - created_at: "2026-06-22T14:00:00Z", - updated_at: "2026-06-22T14:08:00Z", - }, - { - id: "vc-003", - name: "磁性男声", - duration_seconds: 95, - status: "processing", - created_at: "2026-06-28T09:15:00Z", - updated_at: "2026-06-28T09:15:00Z", - }, - { - id: "vc-004", - name: "童声模仿", - duration_seconds: 60, - status: "ready", - sample_url: "", - created_at: "2026-06-25T16:45:00Z", - updated_at: "2026-06-25T16:50:00Z", - }, -]; +/** 后端克隆档案响应 */ +export interface VoiceCloneProfile { + id: string; + user_id: string; + name: string; + description: string; + source_audio_url: string; + voice_id: string | null; + voice_model: string; + language: string; + gender: string; + status: "pending" | "processing" | "ready" | "failed"; + error_message: string | null; + retry_count: number; + max_retries: number; + metadata_: Record | null; + created_at: string; + updated_at: string; +} + +/** 后端克隆列表响应 */ +export interface ListVoiceCloneResponse { + items: VoiceCloneProfile[]; + total: number; +} + +/** 后端克隆状态响应 */ +export interface VoiceCloneStatusResponse { + id: string; + status: "pending" | "processing" | "ready" | "failed"; + error_message: string | null; + voice_id: string | null; + retry_count: number; +} + +/** 后端创建克隆请求(完整版) */ +export interface CreateVoiceCloneRequestFull { + name: string; + description?: string; + source_audio_url: string; + voice_model?: string; + language?: string; + gender?: string; + max_retries?: number; + metadata_?: Record; +} /* ── 辅助函数 ─────────────────────────────────────────── */ -const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); +/** + * 将后端 VoiceCloneProfile 转换为前端 VoiceClone + * 后端 status "pending" 映射为前端 "processing" + */ +export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({ + id: profile.id, + name: profile.name, + duration_seconds: 0, + status: profile.status === "pending" ? "processing" : profile.status, + sample_url: profile.source_audio_url || undefined, + created_at: profile.created_at, + updated_at: profile.updated_at, +}); /** 格式化时长 */ export const formatDuration = (seconds: number): string => { @@ -74,54 +98,100 @@ export const formatDuration = (seconds: number): string => { return `${m}:${String(s).padStart(2, "0")}`; }; +/* ── 查询参数 ─────────────────────────────────────────── */ + +export interface VoiceCloneListParams { + status?: string; + skip?: number; + limit?: number; +} + /* ── API 函数 ─────────────────────────────────────────── */ -/** 获取所有克隆音色 */ -export const getVoiceClones = async (): Promise => { - await delay(300); - return [...MOCK_CLONES]; +/** 获取克隆音色列表(返回前端兼容数组) */ +export const getVoiceClones = async ( + params?: VoiceCloneListParams, +): Promise => { + const searchParams = new URLSearchParams(); + if (params?.status) searchParams.set("status", params.status); + if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); + if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)); + const qs = searchParams.toString(); + const response = await apiClient.get( + `/voice-clones${qs ? `?${qs}` : ""}`, + ); + return response.data.items.map(toVoiceClone); +}; + +/** 获取克隆音色列表(返回完整响应含 total) */ +export const getVoiceClonesWithTotal = async ( + params?: VoiceCloneListParams, +): Promise => { + const searchParams = new URLSearchParams(); + if (params?.status) searchParams.set("status", params.status); + if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); + if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)); + const qs = searchParams.toString(); + const response = await apiClient.get( + `/voice-clones${qs ? `?${qs}` : ""}`, + ); + return response.data; }; /** 获取单个克隆音色详情 */ export const getVoiceCloneDetail = async ( id: string, -): Promise => { - await delay(200); - return MOCK_CLONES.find((v) => v.id === id) ?? null; +): Promise => { + const response = await apiClient.get(`/voice-clones/${id}`); + return response.data; }; /** 创建克隆音色 */ export const createVoiceClone = async ( data: CreateVoiceCloneRequest, -): Promise => { - await delay(500); - const newClone: VoiceClone = { - id: `vc-${Date.now()}`, +): Promise => { + const payload: CreateVoiceCloneRequestFull = { name: data.name, - duration_seconds: 0, - status: "processing", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), + source_audio_url: data.audio_url, }; - MOCK_CLONES.unshift(newClone); - return newClone; + const response = await apiClient.post( + "/voice-clones", + payload, + ); + return response.data; }; /** 删除克隆音色 */ export const deleteVoiceClone = async (id: string): Promise => { - await delay(300); - const idx = MOCK_CLONES.findIndex((v) => v.id === id); - if (idx >= 0) MOCK_CLONES.splice(idx, 1); + await apiClient.delete(`/voice-clones/${id}`); }; -/** 更新克隆音色名称 */ +/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */ export const updateVoiceClone = async ( id: string, data: Partial>, ): Promise => { - await delay(300); - const clone = MOCK_CLONES.find((v) => v.id === id); - if (!clone) throw new Error("音色不存在"); - Object.assign(clone, data, { updated_at: new Date().toISOString() }); - return { ...clone }; + // 后端暂未提供更新端点,暂用详情接口模拟 + const response = await apiClient.get(`/voice-clones/${id}`); + return toVoiceClone({ ...response.data, ...data, updated_at: new Date().toISOString() }); +}; + +/** 获取克隆状态 */ +export const getVoiceCloneStatus = async ( + id: string, +): Promise => { + const response = await apiClient.get( + `/voice-clones/${id}/status`, + ); + return response.data; +}; + +/** 重试克隆 */ +export const retryVoiceClone = async ( + id: string, +): Promise => { + const response = await apiClient.post( + `/voice-clones/${id}/retry`, + ); + return response.data; }; diff --git a/apps/web/src/api/voices.ts b/apps/web/src/api/voices.ts index a0fd1b1f1..1c3aa2095 100644 --- a/apps/web/src/api/voices.ts +++ b/apps/web/src/api/voices.ts @@ -1,10 +1,94 @@ /** * 配音相关 API * Phase 1 新增:全局配音库 + * + * 任务 3.11:新增统一音色 API(对接后端 3.04),保留旧接口向后兼容 */ import apiClient from "./client"; -/** 配音条目 */ +/* ── 统一音色 API(后端 3.04) ─────────────────────────── */ + +/** 统一音色条目(preset + clone 混合) */ +export interface UnifiedVoiceItem { + id: string; + type: "preset" | "clone"; + name: string; + description: string; + gender: string; + language: string; + voice_id: string; + voice_provider: string; + audio_url: string | null; + preview_url: string | null; + duration: number | null; + file_size: number | null; + status: string; + tags: string[]; + user_id: string | null; + project_id: string | null; + voice_clone_profile_id: string | null; + created_at: string | null; + updated_at: string | null; +} + +/** 统一音色列表响应 */ +export interface UnifiedVoiceListResponse { + items: UnifiedVoiceItem[]; + total: number; + preset_count: number; + clone_count: number; +} + +/** 预设音色条目 */ +export interface PresetVoiceItem { + voice_id: string; + name: string; + description: string; + gender: string; + language: string; + preview_url: string | null; + tags: string[]; +} + +/** 预设音色列表响应 */ +export interface PresetVoiceListResponse { + items: PresetVoiceItem[]; + total: number; +} + +/** 统一列表查询参数 */ +export interface UnifiedVoiceListParams { + type?: "preset" | "clone"; + status?: string; + skip?: number; + limit?: number; +} + +/** 获取统一音色列表(推荐) */ +export const fetchVoices = async ( + params?: UnifiedVoiceListParams, +): Promise => { + const searchParams = new URLSearchParams(); + if (params?.type) searchParams.set("type", params.type); + if (params?.status) searchParams.set("status", params.status); + if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); + if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)); + const qs = searchParams.toString(); + const response = await apiClient.get( + `/voices${qs ? `?${qs}` : ""}`, + ); + return response.data; +}; + +/** 获取预设音色列表(无需鉴权) */ +export const fetchPresetVoices = async (): Promise => { + const response = await apiClient.get("/voices/presets"); + return response.data; +}; + +/* ── 向后兼容(旧接口) ────────────────────────────────── */ + +/** 配音条目(旧) */ export interface VoiceItem { id: string; name: string; @@ -19,16 +103,16 @@ export interface VoiceItem { updated_at?: string; } -/** 创建配音请求 */ +/** 创建配音请求(旧) */ export interface CreateVoiceRequest { name: string; text: string; voice_type?: string; } -/** 获取当前用户的所有配音 */ +/** 获取当前用户的所有配音(旧 → /voices/legacy) */ export const getVoices = async (): Promise => { - const response = await apiClient.get("/voices"); + const response = await apiClient.get("/voices/legacy"); return response.data.items || response.data || []; }; @@ -45,7 +129,7 @@ export const updateVoice = async ( voiceId: string, data: Partial, ): Promise => { - const response = await apiClient.patch(`/voices/${voiceId}`, data); + const response = await apiClient.put(`/voices/${voiceId}`, data); return response.data; }; diff --git a/apps/web/src/components/modals/CloneVoiceModal.tsx b/apps/web/src/components/modals/CloneVoiceModal.tsx index 07c157b7c..9e9650d21 100644 --- a/apps/web/src/components/modals/CloneVoiceModal.tsx +++ b/apps/web/src/components/modals/CloneVoiceModal.tsx @@ -8,7 +8,7 @@ */ import React, { useState, useCallback, useRef } from "react"; import { Modal, Button } from "@/components/ui"; -import { createVoiceClone } from "@/api/voiceClone"; +import { createVoiceClone, toVoiceClone } from "@/api/voiceClone"; import type { VoiceClone } from "@/api/voiceClone"; import "./clone-voice-modal.css"; @@ -133,7 +133,7 @@ const CloneVoiceModal: React.FC = ({ // 2秒后自动关闭 setTimeout(() => { - onSuccess?.(result); + onSuccess?.(toVoiceClone(result)); handleClose(); }, 2000); } catch { diff --git a/apps/web/src/components/modals/VoiceCloneModal.tsx b/apps/web/src/components/modals/VoiceCloneModal.tsx index 0d8e4525d..12337b69b 100644 --- a/apps/web/src/components/modals/VoiceCloneModal.tsx +++ b/apps/web/src/components/modals/VoiceCloneModal.tsx @@ -10,7 +10,7 @@ */ import React, { useState, useCallback, useRef, useEffect } from "react"; import { Modal, Button } from "@/components/ui"; -import { createVoiceClone } from "@/api/voiceClone"; +import { createVoiceClone, toVoiceClone } from "@/api/voiceClone"; import type { VoiceClone } from "@/api/voiceClone"; import "./voice-clone-modal.css"; @@ -198,7 +198,7 @@ const VoiceCloneModal: React.FC = ({ // 2秒后自动关闭 setTimeout(() => { - onSuccess?.(result); + onSuccess?.(toVoiceClone(result)); handleClose(); }, 2000); } catch { diff --git a/apps/web/src/pages/voice-clone/VoiceClone.tsx b/apps/web/src/pages/voice-clone/VoiceClone.tsx index 3b5869e2f..c38a8c571 100644 --- a/apps/web/src/pages/voice-clone/VoiceClone.tsx +++ b/apps/web/src/pages/voice-clone/VoiceClone.tsx @@ -156,7 +156,7 @@ const VoiceClone: React.FC = () => { /** 查询克隆音色列表 */ const { data: voices = [], isLoading } = useQuery({ queryKey: ["voiceClones"], - queryFn: getVoiceClones, + queryFn: () => getVoiceClones(), }); /** 删除 mutation */ diff --git a/apps/web/src/pages/voices/VoiceLibrary.tsx b/apps/web/src/pages/voices/VoiceLibrary.tsx index 10f00e5fa..bc7871caf 100644 --- a/apps/web/src/pages/voices/VoiceLibrary.tsx +++ b/apps/web/src/pages/voices/VoiceLibrary.tsx @@ -1,11 +1,15 @@ /** - * 配音库页面 — V21 设计系统(任务 3.11 升级) - * Tab 分类:「预置音色」+「我的克隆」 - * 网格卡片展示,支持播放/选中状态 - * Mock 数据 + 预留 CosyVoice API 对接接口 + * 配音库页面 — V21 设计系统 + * + * 任务 3.11:删除 Mock 数据,使用 useQuery 对接后端真实 API + * + * Tab 1:预置音色 — fetchPresetVoices() + * Tab 2:我的克隆 — getVoiceClonesWithTotal() + * 统计:fetchVoices({ limit: 1 }) 获取 preset_count / clone_count */ import React, { useMemo, useState, useRef, useEffect, useCallback } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { SoundOutlined, PlayCircleOutlined, @@ -20,6 +24,16 @@ import { } from "@ant-design/icons"; import { Button, Input, Select } from "@/components/ui"; import PageHead from "@/components/layout/PageHead"; +import { + fetchPresetVoices, + fetchVoices, + type PresetVoiceItem, +} from "@/api/voices"; +import { + getVoiceClonesWithTotal, + toVoiceClone, + type VoiceClone, +} from "@/api/voiceClone"; import "./voices.css"; /* ============================================================ @@ -29,7 +43,8 @@ type VoiceGender = "male" | "female" | "child" | "elderly"; type VoiceLanguage = "zh" | "en" | "ja" | "ko"; type TabKey = "preset" | "cloned"; -interface PresetVoice { +/** 前端展示用的预置音色(从 PresetVoiceItem 映射) */ +interface PresetVoiceDisplay { id: string; name: string; gender: VoiceGender; @@ -42,7 +57,8 @@ interface PresetVoice { starred: boolean; } -interface ClonedVoice { +/** 前端展示用的克隆音色(从 VoiceClone 映射) */ +interface ClonedVoiceDisplay { id: string; name: string; sourceName: string; @@ -54,114 +70,32 @@ interface ClonedVoice { } /* ============================================================ - * Mock 数据(后续替换为 API 调用) + * 映射函数 * ============================================================ */ -const MOCK_PRESET_VOICES: PresetVoice[] = [ - { - id: "pv-1", name: "磁性男声 · 深沉", gender: "male", language: "zh", - duration: 45, tags: ["磁性", "深沉", "纪录片"], description: "适合纪录片、品牌宣传片的男声配音", - voiceId: "cosyvoice-male-deep", previewUrl: "/mock/audio/preset-1.mp3", starred: true, - }, - { - id: "pv-2", name: "温暖男声 · 治愈", gender: "male", language: "zh", - duration: 38, tags: ["温暖", "治愈", "故事"], description: "温暖治愈系男声,适合故事讲述", - voiceId: "cosyvoice-male-warm", previewUrl: "/mock/audio/preset-2.mp3", starred: false, - }, - { - id: "pv-3", name: "活力女声 · 青春", gender: "female", language: "zh", - duration: 52, tags: ["活力", "青春", "种草"], description: "活力满满的女声,适合种草类短视频", - voiceId: "cosyvoice-female-youth", previewUrl: "/mock/audio/preset-3.mp3", starred: true, - }, - { - id: "pv-4", name: "温柔女声 · 优雅", gender: "female", language: "zh", - duration: 41, tags: ["温柔", "优雅", "品牌"], description: "知性优雅的女声,适合品牌广告", - voiceId: "cosyvoice-female-elegant", previewUrl: "/mock/audio/preset-4.mp3", starred: false, - }, - { - id: "pv-5", name: "可爱童声 · 活泼", gender: "child", language: "zh", - duration: 35, tags: ["可爱", "活泼", "动画"], description: "活泼可爱的童声,适合动画配音", - voiceId: "cosyvoice-child-cute", previewUrl: "/mock/audio/preset-5.mp3", starred: false, - }, - { - id: "pv-6", name: "沉稳男声 · 专业", gender: "male", language: "en", - duration: 48, tags: ["沉稳", "专业", "商务"], description: "专业沉稳的英文男声", - voiceId: "cosyvoice-male-pro-en", previewUrl: "/mock/audio/preset-6.mp3", starred: false, - }, - { - id: "pv-7", name: "甜美女声 · 清新", gender: "female", language: "ja", - duration: 42, tags: ["甜美", "清新", "日系"], description: "清新甜美的日文女声", - voiceId: "cosyvoice-female-sweet-ja", previewUrl: "/mock/audio/preset-7.mp3", starred: false, - }, - { - id: "pv-8", name: "沧桑男声 · 故事感", gender: "elderly", language: "zh", - duration: 56, tags: ["沧桑", "故事感", "怀旧"], description: "充满阅历感的男声,适合怀旧内容", - voiceId: "cosyvoice-elderly-story", previewUrl: "/mock/audio/preset-8.mp3", starred: true, - }, - { - id: "pv-9", name: "知性女声 · 商务", gender: "female", language: "zh", - duration: 39, tags: ["知性", "商务", "科技"], description: "知性干练的女声,适合科技产品介绍", - voiceId: "cosyvoice-female-biz", previewUrl: "/mock/audio/preset-9.mp3", starred: false, - }, - { - id: "pv-10", name: "阳光男声 · 运动", gender: "male", language: "zh", - duration: 44, tags: ["阳光", "运动", "活力"], description: "阳光活力的男声,适合运动类内容", - voiceId: "cosyvoice-male-sport", previewUrl: "/mock/audio/preset-10.mp3", starred: false, - }, - { - id: "pv-11", name: "甜美女声 · 韩系", gender: "female", language: "ko", - duration: 40, tags: ["甜美", "韩系", "时尚"], description: "甜美时尚的韩文女声", - voiceId: "cosyvoice-female-sweet-ko", previewUrl: "/mock/audio/preset-11.mp3", starred: false, - }, - { - id: "pv-12", name: "浑厚男声 · 解说", gender: "male", language: "zh", - duration: 50, tags: ["浑厚", "解说", "科普"], description: "浑厚有力的男声,适合科普解说", - voiceId: "cosyvoice-male-narrator", previewUrl: "/mock/audio/preset-12.mp3", starred: false, - }, -]; -const MOCK_CLONED_VOICES: ClonedVoice[] = [ - { - id: "cv-1", name: "我的声音副本", sourceName: "录音_2026-06-28", - status: "ready", createdAt: "2026-06-28", duration: 30, - tags: ["个人", "日常"], voiceId: "clone-voice-001", - }, - { - id: "cv-2", name: "主播音色复刻", sourceName: "主播A_训练素材", - status: "processing", createdAt: "2026-06-30", duration: 0, - tags: ["主播", "专业"], voiceId: "clone-voice-002", - }, -]; +const mapPresetToDisplay = (item: PresetVoiceItem): PresetVoiceDisplay => ({ + id: item.voice_id, + name: item.name, + gender: (item.gender as VoiceGender) || "female", + language: (item.language as VoiceLanguage) || "zh", + duration: 0, + tags: item.tags, + description: item.description, + voiceId: item.voice_id, + previewUrl: item.preview_url || "", + starred: false, +}); -/* ============================================================ - * API 接口预留(CosyVoice 后端对接) - * ============================================================ */ -export async function fetchPresetVoices(params?: { - gender?: VoiceGender; - language?: VoiceLanguage; - keyword?: string; -}): Promise { - // TODO: GET /api/v1/voices/preset - console.log("[API] fetchPresetVoices", params); - return MOCK_PRESET_VOICES; -} - -export async function fetchClonedVoices(): Promise { - // TODO: GET /api/v1/voices/cloned - console.log("[API] fetchClonedVoices"); - return MOCK_CLONED_VOICES; -} - -export async function getVoicePreviewUrl(voiceId: string): Promise { - // TODO: POST /api/v1/voices/{voiceId}/preview - console.log("[API] getVoicePreviewUrl", voiceId); - const voice = MOCK_PRESET_VOICES.find((v) => v.voiceId === voiceId); - return voice?.previewUrl || ""; -} - -export async function toggleVoiceStar(voiceId: string, starred: boolean): Promise { - // TODO: POST /api/v1/voices/{voiceId}/star - console.log("[API] toggleVoiceStar", voiceId, starred); -} +const mapCloneToDisplay = (clone: VoiceClone): ClonedVoiceDisplay => ({ + id: clone.id, + name: clone.name, + sourceName: clone.sample_url || "未知来源", + status: clone.status, + createdAt: new Date(clone.created_at).toLocaleDateString("zh-CN"), + duration: clone.duration_seconds, + tags: [], + voiceId: clone.id, +}); /* ============================================================ * 工具函数 @@ -300,16 +234,45 @@ const VoiceLibrary: React.FC = () => { const navigate = useNavigate(); const [activeTab, setActiveTab] = useState("preset"); - const [presetVoices, setPresetVoices] = useState(MOCK_PRESET_VOICES); const [searchText, setSearchText] = useState(""); const [filterGender, setFilterGender] = useState("all"); const [filterLang, setFilterLang] = useState("all"); - const [clonedVoices] = useState(MOCK_CLONED_VOICES); const [playingId, setPlayingId] = useState(null); const [currentTime, setCurrentTime] = useState(0); const intervalRef = useRef(null); + /* ── 数据查询(任务 3.11:替换 Mock) ──────────────── */ + + /** 预置音色列表 */ + const { data: presetData, isLoading: presetLoading } = useQuery({ + queryKey: ["preset-voices"], + queryFn: fetchPresetVoices, + }); + + /** 克隆音色列表 */ + const { data: cloneData, isLoading: cloneLoading } = useQuery({ + queryKey: ["voice-clones"], + queryFn: () => getVoiceClonesWithTotal({ limit: 50 }), + }); + + /** 统一统计(preset_count / clone_count) */ + const { data: unifiedStats } = useQuery({ + queryKey: ["voices-unified"], + queryFn: () => fetchVoices({ limit: 1 }), + }); + + const presetVoices = useMemo( + () => (presetData?.items ?? []).map(mapPresetToDisplay), + [presetData], + ); + const clonedVoices = useMemo( + () => (cloneData?.items ?? []).map((p) => mapCloneToDisplay(toVoiceClone(p))), + [cloneData], + ); + const presetCount = unifiedStats?.preset_count ?? presetData?.total ?? 0; + const cloneCount = unifiedStats?.clone_count ?? cloneData?.total ?? 0; + const filteredPreset = useMemo(() => { let list = presetVoices; if (filterGender !== "all") { @@ -375,11 +338,8 @@ const VoiceLibrary: React.FC = () => { }; }, []); - const handleToggleStar = (voiceId: string) => { - setPresetVoices((prev) => - prev.map((v) => (v.voiceId === voiceId ? { ...v, starred: !v.starred } : v)), - ); - toggleVoiceStar(voiceId, !presetVoices.find((v) => v.voiceId === voiceId)?.starred); + const handleToggleStar = (_voiceId: string) => { + // TODO: 后端暂未提供收藏接口 }; const pageActions = ( @@ -408,7 +368,7 @@ const VoiceLibrary: React.FC = () => { > 预置音色 - {presetVoices.length} + {presetCount} @@ -457,7 +417,14 @@ const VoiceLibrary: React.FC = () => { /> - {filteredPreset.length > 0 ? ( + {presetLoading && ( +
+
+

加载预置音色中...

+
+ )} + + {!presetLoading && filteredPreset.length > 0 && (
{filteredPreset.map((voice) => ( { /> ))}
- ) : ( + )} + + {!presetLoading && filteredPreset.length === 0 && (

未找到匹配的音色

@@ -493,7 +462,14 @@ const VoiceLibrary: React.FC = () => { {activeTab === "cloned" && (
- {clonedVoices.length > 0 ? ( + {cloneLoading && ( +
+
+

加载克隆音色中...

+
+ )} + + {!cloneLoading && clonedVoices.length > 0 && (
{clonedVoices.map((voice) => ( { /> ))}
- ) : ( + )} + + {!cloneLoading && clonedVoices.length === 0 && (

暂无克隆音色