feat: 任务3.11 配音库页面升级 — 对接后端真实API #167
+139
-69
@@ -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<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;
|
||||
};
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user