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
* 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;
};
+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;
};