feat: 任务3.11 配音库页面升级 — 对接后端真实API
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 169h9m48s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 169h9m52s
Deploy / Deploy Staging (push) Failing after 169h11m45s
CI/CD Pipeline / Frontend Lint (push) Failing after 169h12m18s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 169h12m24s
Deploy / Production Browser E2E (push) Failing after 1827h44m53s
Deploy / Deploy Production (push) Failing after 1827h45m28s
Deploy / Build Production Runtime Images (push) Failing after 1827h46m3s

- 重写 voices.ts:新增统一音色 API(fetchVoices/fetchPresetVoices),保留旧接口向后兼容
- 重写 voiceClone.ts:对接后端音色克隆 API,导出 toVoiceClone 类型转换
- 升级 VoiceLibrary.tsx:删除所有 Mock 数据,useQuery 对接 preset/clone/unified 三个接口
- 修复 CloneVoiceModal/VoiceCloneModal:toVoiceClone 包装 createVoiceClone 返回值
- 修复 VoiceClone.tsx:useQuery queryFn 箭头函数包装
This commit is contained in:
灵应
2026-07-02 11:40:33 +08:00
parent 23e3b9cc8e
commit b0aee55ff5
6 changed files with 335 additions and 203 deletions
+139 -69
View File
@@ -1,12 +1,15 @@
/** /**
* 音色克隆 API * 音色克隆 API
* Sprint 4 — mock 数据,后续迁移真实 API * 任务 3.11:替换 Mock 数据,对接后端真实 API(3.05)
*/ */
import apiClient from "./client";
/** 克隆音色状态 */ /* ── 前端兼容类型 ─────────────────────────────────────── */
/** 克隆音色状态(前端展示用) */
export type VoiceCloneStatus = "ready" | "processing" | "failed"; export type VoiceCloneStatus = "ready" | "processing" | "failed";
/** 克隆音色条目 */ /** 克隆音色条目(前端展示用) */
export interface VoiceClone { export interface VoiceClone {
id: string; id: string;
name: string; name: string;
@@ -17,55 +20,76 @@ export interface VoiceClone {
updated_at: string; updated_at: string;
} }
/** 创建克隆请求 */ /** 创建克隆请求(前端简化版) */
export interface CreateVoiceCloneRequest { export interface CreateVoiceCloneRequest {
name: string; name: string;
audio_url: string; audio_url: string;
} }
/* ── Mock 数据 ─────────────────────────────────────────── */ /* ── 后端 API 类型 ────────────────────────────────────── */
const MOCK_CLONES: VoiceClone[] = [ /** 后端克隆档案响应 */
{ export interface VoiceCloneProfile {
id: "vc-001", id: string;
name: "我的播音腔", user_id: string;
duration_seconds: 128, name: string;
status: "ready", description: string;
sample_url: "", source_audio_url: string;
created_at: "2026-06-20T10:30:00Z", voice_id: string | null;
updated_at: "2026-06-20T10:35:00Z", voice_model: string;
}, language: string;
{ gender: string;
id: "vc-002", status: "pending" | "processing" | "ready" | "failed";
name: "温柔女声", error_message: string | null;
duration_seconds: 256, retry_count: number;
status: "ready", max_retries: number;
sample_url: "", metadata_: Record<string, unknown> | null;
created_at: "2026-06-22T14:00:00Z", created_at: string;
updated_at: "2026-06-22T14:08:00Z", updated_at: string;
}, }
{
id: "vc-003", /** 后端克隆列表响应 */
name: "磁性男声", export interface ListVoiceCloneResponse {
duration_seconds: 95, items: VoiceCloneProfile[];
status: "processing", total: number;
created_at: "2026-06-28T09:15:00Z", }
updated_at: "2026-06-28T09:15:00Z",
}, /** 后端克隆状态响应 */
{ export interface VoiceCloneStatusResponse {
id: "vc-004", id: string;
name: "童声模仿", status: "pending" | "processing" | "ready" | "failed";
duration_seconds: 60, error_message: string | null;
status: "ready", voice_id: string | null;
sample_url: "", retry_count: number;
created_at: "2026-06-25T16:45:00Z", }
updated_at: "2026-06-25T16:50:00Z",
}, /** 后端创建克隆请求(完整版) */
]; 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 => { export const formatDuration = (seconds: number): string => {
@@ -74,54 +98,100 @@ export const formatDuration = (seconds: number): string => {
return `${m}:${String(s).padStart(2, "0")}`; return `${m}:${String(s).padStart(2, "0")}`;
}; };
/* ── 查询参数 ─────────────────────────────────────────── */
export interface VoiceCloneListParams {
status?: string;
skip?: number;
limit?: number;
}
/* ── API 函数 ─────────────────────────────────────────── */ /* ── API 函数 ─────────────────────────────────────────── */
/** 获取所有克隆音色 */ /** 获取克隆音色列表(返回前端兼容数组) */
export const getVoiceClones = async (): Promise<VoiceClone[]> => { export const getVoiceClones = async (
await delay(300); params?: VoiceCloneListParams,
return [...MOCK_CLONES]; ): 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 ( export const getVoiceCloneDetail = async (
id: string, id: string,
): Promise<VoiceClone | null> => { ): Promise<VoiceCloneProfile> => {
await delay(200); const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`);
return MOCK_CLONES.find((v) => v.id === id) ?? null; return response.data;
}; };
/** 创建克隆音色 */ /** 创建克隆音色 */
export const createVoiceClone = async ( export const createVoiceClone = async (
data: CreateVoiceCloneRequest, data: CreateVoiceCloneRequest,
): Promise<VoiceClone> => { ): Promise<VoiceCloneProfile> => {
await delay(500); const payload: CreateVoiceCloneRequestFull = {
const newClone: VoiceClone = {
id: `vc-${Date.now()}`,
name: data.name, name: data.name,
duration_seconds: 0, source_audio_url: data.audio_url,
status: "processing",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}; };
MOCK_CLONES.unshift(newClone); const response = await apiClient.post<VoiceCloneProfile>(
return newClone; "/voice-clones",
payload,
);
return response.data;
}; };
/** 删除克隆音色 */ /** 删除克隆音色 */
export const deleteVoiceClone = async (id: string): Promise<void> => { export const deleteVoiceClone = async (id: string): Promise<void> => {
await delay(300); await apiClient.delete(`/voice-clones/${id}`);
const idx = MOCK_CLONES.findIndex((v) => v.id === id);
if (idx >= 0) MOCK_CLONES.splice(idx, 1);
}; };
/** 更新克隆音色名称 */ /** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */
export const updateVoiceClone = async ( export const updateVoiceClone = async (
id: string, id: string,
data: Partial<Pick<VoiceClone, "name">>, data: Partial<Pick<VoiceClone, "name">>,
): Promise<VoiceClone> => { ): Promise<VoiceClone> => {
await delay(300); // 后端暂未提供更新端点,暂用详情接口模拟
const clone = MOCK_CLONES.find((v) => v.id === id); const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`);
if (!clone) throw new Error("音色不存在"); return toVoiceClone({ ...response.data, ...data, updated_at: new Date().toISOString() });
Object.assign(clone, data, { updated_at: new Date().toISOString() }); };
return { ...clone };
/** 获取克隆状态 */
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 * 配音相关 API
* Phase 1 新增:全局配音库 * Phase 1 新增:全局配音库
*
* 任务 3.11:新增统一音色 API(对接后端 3.04),保留旧接口向后兼容
*/ */
import apiClient from "./client"; 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 { export interface VoiceItem {
id: string; id: string;
name: string; name: string;
@@ -19,16 +103,16 @@ export interface VoiceItem {
updated_at?: string; updated_at?: string;
} }
/** 创建配音请求 */ /** 创建配音请求(旧) */
export interface CreateVoiceRequest { export interface CreateVoiceRequest {
name: string; name: string;
text: string; text: string;
voice_type?: string; voice_type?: string;
} }
/** 获取当前用户的所有配音 */ /** 获取当前用户的所有配音(旧 → /voices/legacy) */
export const getVoices = async (): Promise<VoiceItem[]> => { 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 || []; return response.data.items || response.data || [];
}; };
@@ -45,7 +129,7 @@ export const updateVoice = async (
voiceId: string, voiceId: string,
data: Partial<CreateVoiceRequest>, data: Partial<CreateVoiceRequest>,
): Promise<VoiceItem> => { ): Promise<VoiceItem> => {
const response = await apiClient.patch(`/voices/${voiceId}`, data); const response = await apiClient.put(`/voices/${voiceId}`, data);
return response.data; return response.data;
}; };
@@ -8,7 +8,7 @@
*/ */
import React, { useState, useCallback, useRef } from "react"; import React, { useState, useCallback, useRef } from "react";
import { Modal, Button } from "@/components/ui"; import { Modal, Button } from "@/components/ui";
import { createVoiceClone } from "@/api/voiceClone"; import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone"; import type { VoiceClone } from "@/api/voiceClone";
import "./clone-voice-modal.css"; import "./clone-voice-modal.css";
@@ -133,7 +133,7 @@ const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
// 2秒后自动关闭 // 2秒后自动关闭
setTimeout(() => { setTimeout(() => {
onSuccess?.(result); onSuccess?.(toVoiceClone(result));
handleClose(); handleClose();
}, 2000); }, 2000);
} catch { } catch {
@@ -10,7 +10,7 @@
*/ */
import React, { useState, useCallback, useRef, useEffect } from "react"; import React, { useState, useCallback, useRef, useEffect } from "react";
import { Modal, Button } from "@/components/ui"; import { Modal, Button } from "@/components/ui";
import { createVoiceClone } from "@/api/voiceClone"; import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone"; import type { VoiceClone } from "@/api/voiceClone";
import "./voice-clone-modal.css"; import "./voice-clone-modal.css";
@@ -198,7 +198,7 @@ const VoiceCloneModal: React.FC<VoiceCloneModalProps> = ({
// 2秒后自动关闭 // 2秒后自动关闭
setTimeout(() => { setTimeout(() => {
onSuccess?.(result); onSuccess?.(toVoiceClone(result));
handleClose(); handleClose();
}, 2000); }, 2000);
} catch { } catch {
@@ -156,7 +156,7 @@ const VoiceClone: React.FC = () => {
/** 查询克隆音色列表 */ /** 查询克隆音色列表 */
const { data: voices = [], isLoading } = useQuery({ const { data: voices = [], isLoading } = useQuery({
queryKey: ["voiceClones"], queryKey: ["voiceClones"],
queryFn: getVoiceClones, queryFn: () => getVoiceClones(),
}); });
/** 删除 mutation */ /** 删除 mutation */
+102 -124
View File
@@ -1,11 +1,15 @@
/** /**
* 配音库页面 — V21 设计系统(任务 3.11 升级) * 配音库页面 — V21 设计系统
* Tab 分类:「预置音色」+「我的克隆」 *
* 网格卡片展示,支持播放/选中状态 * 任务 3.11:删除 Mock 数据,使用 useQuery 对接后端真实 API
* Mock 数据 + 预留 CosyVoice API 对接接口 *
* Tab 1:预置音色 — fetchPresetVoices()
* Tab 2:我的克隆 — getVoiceClonesWithTotal()
* 统计:fetchVoices({ limit: 1 }) 获取 preset_count / clone_count
*/ */
import React, { useMemo, useState, useRef, useEffect, useCallback } from "react"; import React, { useMemo, useState, useRef, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { import {
SoundOutlined, SoundOutlined,
PlayCircleOutlined, PlayCircleOutlined,
@@ -20,6 +24,16 @@ import {
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Button, Input, Select } from "@/components/ui"; import { Button, Input, Select } from "@/components/ui";
import PageHead from "@/components/layout/PageHead"; 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"; import "./voices.css";
/* ============================================================ /* ============================================================
@@ -29,7 +43,8 @@ type VoiceGender = "male" | "female" | "child" | "elderly";
type VoiceLanguage = "zh" | "en" | "ja" | "ko"; type VoiceLanguage = "zh" | "en" | "ja" | "ko";
type TabKey = "preset" | "cloned"; type TabKey = "preset" | "cloned";
interface PresetVoice { /** 前端展示用的预置音色(从 PresetVoiceItem 映射) */
interface PresetVoiceDisplay {
id: string; id: string;
name: string; name: string;
gender: VoiceGender; gender: VoiceGender;
@@ -42,7 +57,8 @@ interface PresetVoice {
starred: boolean; starred: boolean;
} }
interface ClonedVoice { /** 前端展示用的克隆音色(从 VoiceClone 映射) */
interface ClonedVoiceDisplay {
id: string; id: string;
name: string; name: string;
sourceName: 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[] = [ const mapPresetToDisplay = (item: PresetVoiceItem): PresetVoiceDisplay => ({
{ id: item.voice_id,
id: "cv-1", name: "我的声音副本", sourceName: "录音_2026-06-28", name: item.name,
status: "ready", createdAt: "2026-06-28", duration: 30, gender: (item.gender as VoiceGender) || "female",
tags: ["个人", "日常"], voiceId: "clone-voice-001", language: (item.language as VoiceLanguage) || "zh",
}, duration: 0,
{ tags: item.tags,
id: "cv-2", name: "主播音色复刻", sourceName: "主播A_训练素材", description: item.description,
status: "processing", createdAt: "2026-06-30", duration: 0, voiceId: item.voice_id,
tags: ["主播", "专业"], voiceId: "clone-voice-002", previewUrl: item.preview_url || "",
}, starred: false,
]; });
/* ============================================================ const mapCloneToDisplay = (clone: VoiceClone): ClonedVoiceDisplay => ({
* API 接口预留(CosyVoice 后端对接) id: clone.id,
* ============================================================ */ name: clone.name,
export async function fetchPresetVoices(params?: { sourceName: clone.sample_url || "未知来源",
gender?: VoiceGender; status: clone.status,
language?: VoiceLanguage; createdAt: new Date(clone.created_at).toLocaleDateString("zh-CN"),
keyword?: string; duration: clone.duration_seconds,
}): Promise<PresetVoice[]> { tags: [],
// TODO: GET /api/v1/voices/preset voiceId: clone.id,
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);
}
/* ============================================================ /* ============================================================
* 工具函数 * 工具函数
@@ -300,16 +234,45 @@ const VoiceLibrary: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<TabKey>("preset"); const [activeTab, setActiveTab] = useState<TabKey>("preset");
const [presetVoices, setPresetVoices] = useState<PresetVoice[]>(MOCK_PRESET_VOICES);
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [filterGender, setFilterGender] = useState<string>("all"); const [filterGender, setFilterGender] = useState<string>("all");
const [filterLang, setFilterLang] = useState<string>("all"); const [filterLang, setFilterLang] = useState<string>("all");
const [clonedVoices] = useState<ClonedVoice[]>(MOCK_CLONED_VOICES);
const [playingId, setPlayingId] = useState<string | null>(null); const [playingId, setPlayingId] = useState<string | null>(null);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const intervalRef = useRef<number | null>(null); 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(() => { const filteredPreset = useMemo(() => {
let list = presetVoices; let list = presetVoices;
if (filterGender !== "all") { if (filterGender !== "all") {
@@ -375,11 +338,8 @@ const VoiceLibrary: React.FC = () => {
}; };
}, []); }, []);
const handleToggleStar = (voiceId: string) => { const handleToggleStar = (_voiceId: string) => {
setPresetVoices((prev) => // TODO: 后端暂未提供收藏接口
prev.map((v) => (v.voiceId === voiceId ? { ...v, starred: !v.starred } : v)),
);
toggleVoiceStar(voiceId, !presetVoices.find((v) => v.voiceId === voiceId)?.starred);
}; };
const pageActions = ( const pageActions = (
@@ -408,7 +368,7 @@ const VoiceLibrary: React.FC = () => {
> >
<AudioOutlined /> <AudioOutlined />
预置音色 预置音色
<span className="xx-voices-tab-count">{presetVoices.length}</span> <span className="xx-voices-tab-count">{presetCount}</span>
</button> </button>
<button <button
className={`xx-voices-tab${activeTab === "cloned" ? " active" : ""}`} className={`xx-voices-tab${activeTab === "cloned" ? " active" : ""}`}
@@ -416,7 +376,7 @@ const VoiceLibrary: React.FC = () => {
> >
<UserOutlined /> <UserOutlined />
我的克隆 我的克隆
<span className="xx-voices-tab-count">{clonedVoices.length}</span> <span className="xx-voices-tab-count">{cloneCount}</span>
</button> </button>
</div> </div>
@@ -457,7 +417,14 @@ const VoiceLibrary: React.FC = () => {
/> />
</div> </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"> <div className="xx-voice-grid">
{filteredPreset.map((voice) => ( {filteredPreset.map((voice) => (
<VoiceCard <VoiceCard
@@ -479,7 +446,9 @@ const VoiceLibrary: React.FC = () => {
/> />
))} ))}
</div> </div>
) : ( )}
{!presetLoading && filteredPreset.length === 0 && (
<div className="xx-voices-empty"> <div className="xx-voices-empty">
<div className="xx-voices-empty-icon"><SoundOutlined /></div> <div className="xx-voices-empty-icon"><SoundOutlined /></div>
<p>未找到匹配的音色</p> <p>未找到匹配的音色</p>
@@ -493,7 +462,14 @@ const VoiceLibrary: React.FC = () => {
{activeTab === "cloned" && ( {activeTab === "cloned" && (
<div className="xx-voices-tab-content"> <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"> <div className="xx-voice-grid">
{clonedVoices.map((voice) => ( {clonedVoices.map((voice) => (
<VoiceCard <VoiceCard
@@ -514,7 +490,9 @@ const VoiceLibrary: React.FC = () => {
/> />
))} ))}
</div> </div>
) : ( )}
{!cloneLoading && clonedVoices.length === 0 && (
<div className="xx-voices-empty"> <div className="xx-voices-empty">
<div className="xx-voices-empty-icon"><UserOutlined /></div> <div className="xx-voices-empty-icon"><UserOutlined /></div>
<h3>暂无克隆音色</h3> <h3>暂无克隆音色</h3>