feat: 任务3.11 配音库页面升级 — 对接后端真实API #167

Merged
xiaoxia merged 1 commits from feature/task-311-voice-library-api into develop 2026-07-02 11:54:07 +08:00
6 changed files with 335 additions and 203 deletions
+139 -69
View File
@@ -1,12 +1,15 @@
/**
* 音色克隆 API
* Sprint 4 — mock 数据,后续迁移真实 API
* 任务 3.11:替换 Mock 数据,对接后端真实 API3.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<string, unknown> | 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<string, unknown>;
}
/* ── 辅助函数 ─────────────────────────────────────────── */
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<VoiceClone[]> => {
await delay(300);
return [...MOCK_CLONES];
/** 获取克隆音色列表(返回前端兼容数组) */
export const getVoiceClones = async (
params?: VoiceCloneListParams,
): Promise<VoiceClone[]> => {
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<ListVoiceCloneResponse>(
`/voice-clones${qs ? `?${qs}` : ""}`,
);
return response.data.items.map(toVoiceClone);
};
/** 获取克隆音色列表(返回完整响应含 total) */
export const getVoiceClonesWithTotal = async (
params?: VoiceCloneListParams,
): Promise<ListVoiceCloneResponse> => {
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<ListVoiceCloneResponse>(
`/voice-clones${qs ? `?${qs}` : ""}`,
);
return response.data;
};
/** 获取单个克隆音色详情 */
export const getVoiceCloneDetail = async (
id: string,
): Promise<VoiceClone | null> => {
await delay(200);
return MOCK_CLONES.find((v) => v.id === id) ?? null;
): Promise<VoiceCloneProfile> => {
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`);
return response.data;
};
/** 创建克隆音色 */
export const createVoiceClone = async (
data: CreateVoiceCloneRequest,
): Promise<VoiceClone> => {
await delay(500);
const newClone: VoiceClone = {
id: `vc-${Date.now()}`,
): Promise<VoiceCloneProfile> => {
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<VoiceCloneProfile>(
"/voice-clones",
payload,
);
return response.data;
};
/** 删除克隆音色 */
export const deleteVoiceClone = async (id: string): Promise<void> => {
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<Pick<VoiceClone, "name">>,
): Promise<VoiceClone> => {
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<VoiceCloneProfile>(`/voice-clones/${id}`);
return toVoiceClone({ ...response.data, ...data, updated_at: new Date().toISOString() });
};
/** 获取克隆状态 */
export const getVoiceCloneStatus = async (
id: string,
): Promise<VoiceCloneStatusResponse> => {
const response = await apiClient.get<VoiceCloneStatusResponse>(
`/voice-clones/${id}/status`,
);
return response.data;
};
/** 重试克隆 */
export const retryVoiceClone = async (
id: string,
): Promise<VoiceCloneProfile> => {
const response = await apiClient.post<VoiceCloneProfile>(
`/voice-clones/${id}/retry`,
);
return response.data;
};
+89 -5
View File
@@ -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<UnifiedVoiceListResponse> => {
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<UnifiedVoiceListResponse>(
`/voices${qs ? `?${qs}` : ""}`,
);
return response.data;
};
/** 获取预设音色列表(无需鉴权) */
export const fetchPresetVoices = async (): Promise<PresetVoiceListResponse> => {
const response = await apiClient.get<PresetVoiceListResponse>("/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<VoiceItem[]> => {
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<CreateVoiceRequest>,
): Promise<VoiceItem> => {
const response = await apiClient.patch(`/voices/${voiceId}`, data);
const response = await apiClient.put(`/voices/${voiceId}`, data);
return response.data;
};
@@ -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<CloneVoiceModalProps> = ({
// 2秒后自动关闭
setTimeout(() => {
onSuccess?.(result);
onSuccess?.(toVoiceClone(result));
handleClose();
}, 2000);
} catch {
@@ -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<VoiceCloneModalProps> = ({
// 2秒后自动关闭
setTimeout(() => {
onSuccess?.(result);
onSuccess?.(toVoiceClone(result));
handleClose();
}, 2000);
} catch {
@@ -156,7 +156,7 @@ const VoiceClone: React.FC = () => {
/** 查询克隆音色列表 */
const { data: voices = [], isLoading } = useQuery({
queryKey: ["voiceClones"],
queryFn: getVoiceClones,
queryFn: () => getVoiceClones(),
});
/** 删除 mutation */
+102 -124
View File
@@ -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<PresetVoice[]> {
// TODO: GET /api/v1/voices/preset
console.log("[API] fetchPresetVoices", params);
return MOCK_PRESET_VOICES;
}
export async function fetchClonedVoices(): Promise<ClonedVoice[]> {
// TODO: GET /api/v1/voices/cloned
console.log("[API] fetchClonedVoices");
return MOCK_CLONED_VOICES;
}
export async function getVoicePreviewUrl(voiceId: string): Promise<string> {
// 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<void> {
// 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<TabKey>("preset");
const [presetVoices, setPresetVoices] = useState<PresetVoice[]>(MOCK_PRESET_VOICES);
const [searchText, setSearchText] = useState("");
const [filterGender, setFilterGender] = useState<string>("all");
const [filterLang, setFilterLang] = useState<string>("all");
const [clonedVoices] = useState<ClonedVoice[]>(MOCK_CLONED_VOICES);
const [playingId, setPlayingId] = useState<string | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const intervalRef = useRef<number | null>(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 = () => {
>
<AudioOutlined />
<span className="xx-voices-tab-count">{presetVoices.length}</span>
<span className="xx-voices-tab-count">{presetCount}</span>
</button>
<button
className={`xx-voices-tab${activeTab === "cloned" ? " active" : ""}`}
@@ -416,7 +376,7 @@ const VoiceLibrary: React.FC = () => {
>
<UserOutlined />
<span className="xx-voices-tab-count">{clonedVoices.length}</span>
<span className="xx-voices-tab-count">{cloneCount}</span>
</button>
</div>
@@ -457,7 +417,14 @@ const VoiceLibrary: React.FC = () => {
/>
</div>
{filteredPreset.length > 0 ? (
{presetLoading && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon"><SoundOutlined /></div>
<p>...</p>
</div>
)}
{!presetLoading && filteredPreset.length > 0 && (
<div className="xx-voice-grid">
{filteredPreset.map((voice) => (
<VoiceCard
@@ -479,7 +446,9 @@ const VoiceLibrary: React.FC = () => {
/>
))}
</div>
) : (
)}
{!presetLoading && filteredPreset.length === 0 && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon"><SoundOutlined /></div>
<p></p>
@@ -493,7 +462,14 @@ const VoiceLibrary: React.FC = () => {
{activeTab === "cloned" && (
<div className="xx-voices-tab-content">
{clonedVoices.length > 0 ? (
{cloneLoading && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon"><UserOutlined /></div>
<p>...</p>
</div>
)}
{!cloneLoading && clonedVoices.length > 0 && (
<div className="xx-voice-grid">
{clonedVoices.map((voice) => (
<VoiceCard
@@ -514,7 +490,9 @@ const VoiceLibrary: React.FC = () => {
/>
))}
</div>
) : (
)}
{!cloneLoading && clonedVoices.length === 0 && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon"><UserOutlined /></div>
<h3></h3>