diff --git a/apps/web/src/api/voiceClone.ts b/apps/web/src/api/voiceClone.ts index 9e5ea7f0d..86ab9facb 100644 --- a/apps/web/src/api/voiceClone.ts +++ b/apps/web/src/api/voiceClone.ts @@ -13,9 +13,13 @@ export type VoiceCloneStatus = "ready" | "processing" | "failed"; export interface VoiceClone { id: string; name: string; + description: string; duration_seconds: number; status: VoiceCloneStatus; sample_url?: string; + language: string; + gender: string; + error_message: string | null; created_at: string; updated_at: string; } @@ -84,9 +88,13 @@ export interface CreateVoiceCloneRequestFull { export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({ id: profile.id, name: profile.name, + description: profile.description || "", duration_seconds: 0, status: profile.status === "pending" ? "processing" : profile.status, sample_url: profile.source_audio_url || undefined, + language: profile.language || "", + gender: profile.gender || "", + error_message: profile.error_message || null, created_at: profile.created_at, updated_at: profile.updated_at, }); diff --git a/apps/web/src/pages/voices/VoiceLibrary.tsx b/apps/web/src/pages/voices/VoiceLibrary.tsx index bc7871caf..363551e10 100644 --- a/apps/web/src/pages/voices/VoiceLibrary.tsx +++ b/apps/web/src/pages/voices/VoiceLibrary.tsx @@ -9,7 +9,7 @@ */ import React, { useMemo, useState, useRef, useEffect, useCallback } from "react"; import { useNavigate } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { SoundOutlined, PlayCircleOutlined, @@ -21,8 +21,11 @@ import { AudioOutlined, HeartOutlined, UserOutlined, + DeleteOutlined, + ReloadOutlined, + CloseCircleOutlined, } from "@ant-design/icons"; -import { Button, Input, Select } from "@/components/ui"; +import { Button, Input, Select, Tooltip } from "@/components/ui"; import PageHead from "@/components/layout/PageHead"; import { fetchPresetVoices, @@ -31,6 +34,8 @@ import { } from "@/api/voices"; import { getVoiceClonesWithTotal, + deleteVoiceClone, + retryVoiceClone, toVoiceClone, type VoiceClone, } from "@/api/voiceClone"; @@ -61,12 +66,17 @@ interface PresetVoiceDisplay { interface ClonedVoiceDisplay { id: string; name: string; + description: string; sourceName: string; status: "ready" | "processing" | "failed"; createdAt: string; duration: number; tags: string[]; voiceId: string; + language: string; + gender: string; + errorMessage: string | null; + sampleUrl?: string; } /* ============================================================ @@ -89,12 +99,17 @@ const mapPresetToDisplay = (item: PresetVoiceItem): PresetVoiceDisplay => ({ const mapCloneToDisplay = (clone: VoiceClone): ClonedVoiceDisplay => ({ id: clone.id, name: clone.name, + description: clone.description || "", sourceName: clone.sample_url || "未知来源", status: clone.status, createdAt: new Date(clone.created_at).toLocaleDateString("zh-CN"), duration: clone.duration_seconds, tags: [], voiceId: clone.id, + language: clone.language || "", + gender: clone.gender || "", + errorMessage: clone.error_message || null, + sampleUrl: clone.sample_url || undefined, }); /* ============================================================ @@ -227,6 +242,270 @@ const VoiceCard: React.FC = ({ ); }; +/* ============================================================ + * 克隆音色卡片组件(任务 3.12) + * ============================================================ */ + +/** 状态配置 */ +const CLONE_STATUS_CONFIG: Record< + ClonedVoiceDisplay["status"], + { label: string; className: string } +> = { + ready: { label: "可用", className: "xx-clone-status--ready" }, + processing: { label: "处理中", className: "xx-clone-status--processing" }, + failed: { label: "失败", className: "xx-clone-status--failed" }, +}; + +/** Toast 类型 */ +interface Toast { + id: number; + message: string; + type: "success" | "error"; +} + +let toastIdSeq = 0; + +/** 克隆音色卡片 */ +interface CloneVoiceCardProps { + voice: ClonedVoiceDisplay; + isPlaying: boolean; + currentTime: number; + onPlay: () => void; + onPause: () => void; + onUse: () => void; + onDelete: () => void; + onRetry: () => void; + onShowDetail: () => void; +} + +const CloneVoiceCard: React.FC = ({ + voice, + isPlaying, + currentTime, + onPlay, + onPause, + onUse, + onDelete, + onRetry, + onShowDetail, +}) => { + const statusCfg = CLONE_STATUS_CONFIG[voice.status]; + const isFailed = voice.status === "failed"; + const isProcessing = voice.status === "processing"; + const genderText = voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender; + + return ( +
+ {/* 右上角操作按钮 */} +
+ + + + {isFailed && ( + + + + )} +
+ + {/* 头部:头像 + 名称 + 状态 */} +
+
+ +
+
+

{voice.name}

+ + + {statusCfg.label} + +
+
+ + {/* 描述 */} + {voice.description && ( +

{voice.description}

+ )} + + {/* 元信息 */} +
+ {(voice.gender || voice.language) && ( + + + {genderText}{voice.language ? ` · ${voice.language}` : ""} + + )} + + {voice.createdAt} + +
+ + {/* 错误信息 */} + {isFailed && voice.errorMessage && ( +
+ + {voice.errorMessage} +
+ )} + + {/* 底部操作区 */} +
+ {voice.status === "ready" && ( + <> + +
+
+
+ + + )} + {isProcessing && ( +
+ + 克隆处理中,请稍候... +
+ )} + {isFailed && ( + + )} +
+
+ ); +}; + +/** 克隆音色详情弹窗 */ +const CloneDetailModal: React.FC<{ + voice: ClonedVoiceDisplay; + onClose: () => void; + onUse: () => void; + onDelete: () => void; + onRetry: () => void; +}> = ({ voice, onClose, onUse, onDelete, onRetry }) => { + const statusCfg = CLONE_STATUS_CONFIG[voice.status]; + const genderText = voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender || "未知"; + const langText = voice.language || "未知"; + + return ( +
+
e.stopPropagation()}> + + +
+
+ +
+
+

{voice.name}

+ + + {statusCfg.label} + +
+
+ + {voice.description && ( +

{voice.description}

+ )} + +
+
+ 性别 + {genderText} +
+
+ 语言 + {langText} +
+
+ 来源 + {voice.sourceName} +
+
+ 创建时间 + {voice.createdAt} +
+ {voice.errorMessage && ( +
+ 错误 + {voice.errorMessage} +
+ )} +
+ +
+ {voice.status === "failed" && ( + + )} + + {voice.status === "ready" && ( + + )} +
+
+
+ ); +}; + +/** 骨架屏 */ +const CloneCardSkeleton: React.FC = () => ( +
+
+
+
+
+
+
+
+
+
+); + /* ============================================================ * 主组件 * ============================================================ */ @@ -242,6 +521,42 @@ const VoiceLibrary: React.FC = () => { const [currentTime, setCurrentTime] = useState(0); const intervalRef = useRef(null); + const queryClient = useQueryClient(); + const [detailVoice, setDetailVoice] = useState(null); + const [toasts, setToasts] = useState([]); + + const showToast = useCallback((message: string, type: Toast["type"]) => { + const id = ++toastIdSeq; + setToasts((prev) => [...prev, { id, message, type }]); + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, 3000); + }, []); + + /** 删除克隆音色 */ + const deleteMutation = useMutation({ + mutationFn: deleteVoiceClone, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["voice-clones"] }); + showToast("音色已删除", "success"); + }, + onError: () => { + showToast("删除失败", "error"); + }, + }); + + /** 重试克隆 */ + const retryMutation = useMutation({ + mutationFn: retryVoiceClone, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["voice-clones"] }); + showToast("已重新提交克隆", "success"); + }, + onError: () => { + showToast("重试失败", "error"); + }, + }); + /* ── 数据查询(任务 3.11:替换 Mock) ──────────────── */ /** 预置音色列表 */ @@ -342,6 +657,34 @@ const VoiceLibrary: React.FC = () => { // TODO: 后端暂未提供收藏接口 }; + /** 克隆音色操作 handlers */ + const handleCloneDelete = (voice: ClonedVoiceDisplay) => { + if (window.confirm(`确定删除音色「${voice.name}」吗?`)) { + deleteMutation.mutate(voice.id); + if (detailVoice?.id === voice.id) setDetailVoice(null); + } + }; + + const handleCloneRetry = (voice: ClonedVoiceDisplay) => { + retryMutation.mutate(voice.id); + }; + + const handleCloneUse = (_voice: ClonedVoiceDisplay) => { + showToast("已选择音色", "success"); + }; + + const handleShowDetail = (voice: ClonedVoiceDisplay) => { + setDetailVoice(voice); + }; + + const handleClonePlayPause = (voiceId: string, duration: number) => { + if (playingId === voiceId) { + handlePause(); + } else { + handlePlay(voiceId, duration); + } + }; + const pageActions = (