diff --git a/apps/web/src/pages/voice-clone/VoiceClone.tsx b/apps/web/src/pages/voice-clone/VoiceClone.tsx old mode 100644 new mode 100755 index 1b36571d7..bd0291c66 --- a/apps/web/src/pages/voice-clone/VoiceClone.tsx +++ b/apps/web/src/pages/voice-clone/VoiceClone.tsx @@ -4,232 +4,36 @@ * 展示克隆音色列表,卡片网格布局 * 支持试听、使用、编辑名称、删除操作 */ -import React, { useState, useCallback } from "react" -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { Button, Tooltip } from "@/components/ui" -import { Popconfirm } from "antd" -import { - EditOutlined, - DeleteOutlined, - AudioOutlined, - SoundOutlined, - CalendarOutlined, - CheckCircleOutlined, - CloseCircleOutlined, -} from "@ant-design/icons" +import React from "react" +import { Button } from "@/components/ui" import PageHead from "@/components/layout/PageHead" import CloneModal from "@/components/voice/CloneModal" -import { - getVoiceClones, - deleteVoiceClone, - updateVoiceClone, - formatDuration, - type VoiceClone as VoiceCloneType, -} from "@/api/voice-clone" +import { VoiceCloneCard } from "./components/VoiceCloneCard" +import { VoiceCloneEmpty, VoiceCloneSkeleton, ToastContainer } from "./components/States" +import { EditNameDialog } from "./components/EditNameDialog" +import { useVoiceCloneList } from "./hooks/useVoiceCloneList" import "./voice-clone.css" -/* ── 状态配置 ─────────────────────────────────────────── */ - -const STATUS_CONFIG: Record = { - ready: { label: "就绪", className: "vc-status-pill--ready" }, - processing: { label: "克隆中", className: "vc-status-pill--processing" }, - failed: { label: "失败", className: "vc-status-pill--failed" }, -} - -/* ── Toast 系统 ─────────────────────────────────────────── */ - -interface Toast { - id: number - message: string - type: "success" | "error" -} - -let toastId = 0 - -/* ── 音色卡片组件 ───────────────────────────────────────── */ - -interface VoiceCloneCardProps { - voice: VoiceCloneType - onPlay: (voice: VoiceCloneType) => void - onUse: (voice: VoiceCloneType) => void - onEdit: (voice: VoiceCloneType) => void - onDelete: (voice: VoiceCloneType) => void -} - -const VoiceCloneCard: React.FC = ({ - voice, - onPlay, - onUse, - onEdit, - onDelete, -}) => { - const statusCfg = STATUS_CONFIG[voice.status] - const isProcessing = voice.status === "processing" - const createdDate = new Date(voice.created_at).toLocaleDateString("zh-CN") - - return ( -
- {/* 右上角操作 */} -
- - - - - onDelete(voice)} - okText="删除" - cancelText="取消" - > - - - -
- - {/* 头部:头像 + 名称 + 状态 */} -
-
- -
-
-

{voice.name}

- - - {statusCfg.label} - -
-
- - {/* 元信息 */} -
-
- - - - 时长:{formatDuration(voice.duration_seconds)} -
-
- - - - 创建于:{createdDate} -
-
- - {/* 操作区 */} -
- - -
-
- ) -} - -/* ── 主页面 ─────────────────────────────────────────────── */ - const VoiceClone: React.FC = () => { - const queryClient = useQueryClient() - const [toasts, setToasts] = useState([]) - const [editingVoice, setEditingVoice] = useState(null) - const [editName, setEditName] = useState("") - const [cloneModalOpen, setCloneModalOpen] = useState(false) - - /** 显示 toast */ - const showToast = useCallback((message: string, type: Toast["type"]) => { - const id = ++toastId - setToasts((prev) => [...prev, { id, message, type }]) - setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== id)) - }, 3000) - }, []) - - /** 查询克隆音色列表 */ - const { data: voices = [], isLoading } = useQuery({ - queryKey: ["voiceClones"], - queryFn: () => getVoiceClones(), - }) - - /** 删除 mutation */ - const deleteMutation = useMutation({ - mutationFn: deleteVoiceClone, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["voiceClones"] }) - showToast("音色已删除", "success") - }, - onError: () => { - showToast("删除失败", "error") - }, - }) - - /** 编辑 mutation */ - const updateMutation = useMutation({ - mutationFn: ({ id, name }: { id: string; name: string }) => updateVoiceClone(id, { name }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["voiceClones"] }) - setEditingVoice(null) - showToast("名称已更新", "success") - }, - onError: () => { - showToast("更新失败", "error") - }, - }) - - /** 克隆新音色 — 打开弹窗 */ - const handleCloneNew = () => { - setCloneModalOpen(true) - } - - /** 试听 */ - const handlePlay = (voice: VoiceCloneType) => { - if (voice.sample_url) { - const audio = new Audio(voice.sample_url) - audio.play().catch(() => { - showToast("播放失败", "error") - }) - } else { - showToast("暂无试听音频", "error") - } - } - - /** 使用音色 — 跳转到生成页面 */ - const handleUse = (_voice: VoiceCloneType) => { - showToast("已选择音色,跳转到生成页面", "success") - } - - /** 打开编辑弹窗 */ - const handleEdit = (voice: VoiceCloneType) => { - setEditingVoice(voice) - setEditName(voice.name) - } - - /** 确认编辑 */ - const handleEditConfirm = () => { - if (!editingVoice || !editName.trim()) return - updateMutation.mutate({ id: editingVoice.id, name: editName.trim() }) - } - - /** 删除确认 — 使用 Popconfirm */ - const handleDelete = (voice: VoiceCloneType) => { - deleteMutation.mutate(voice.id) - } + const { + voices, + isLoading, + toasts, + editingVoice, + editName, + setEditName, + cloneModalOpen, + updateLoading, + handleCloneNew, + handleCloseCloneModal, + handleCloneSuccess, + handlePlay, + handleUse, + handleEdit, + handleCloseEdit, + handleEditConfirm, + handleDelete, + } = useVoiceCloneList() return (
@@ -244,24 +48,7 @@ const VoiceClone: React.FC = () => { /> {/* 加载状态 — 骨架屏 */} - {isLoading && ( -
- {[1, 2, 3].map((i) => ( -
-
-
-
-
-
-
-
-
-
-
-
- ))} -
- )} + {isLoading && } {/* 卡片网格 */} {!isLoading && voices.length > 0 && ( @@ -280,71 +67,27 @@ const VoiceClone: React.FC = () => { )} {/* 空状态 */} - {!isLoading && voices.length === 0 && ( -
-
- -
-

还没有克隆音色

-

上传你的声音,AI将克隆你的专属音色

- -
- )} + {!isLoading && voices.length === 0 && } {/* Toast 提示 */} - {toasts.length > 0 && ( -
- {toasts.map((t) => ( -
- {t.type === "success" ? : } {t.message} -
- ))} -
- )} + {/* 编辑弹窗 */} {editingVoice && ( -
setEditingVoice(null)}> -
e.stopPropagation()}> -

编辑音色名称

- setEditName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleEditConfirm() - if (e.key === "Escape") setEditingVoice(null) - }} - autoFocus - placeholder="输入音色名称" - /> -
- - -
-
-
+ )} {/* 克隆音色弹窗 */} setCloneModalOpen(false)} - onSuccess={() => { - queryClient.invalidateQueries({ queryKey: ["voiceClones"] }) - showToast("音色克隆已提交", "success") - }} + onClose={handleCloseCloneModal} + onSuccess={handleCloneSuccess} />
) diff --git a/apps/web/src/pages/voice-clone/components/EditNameDialog.tsx b/apps/web/src/pages/voice-clone/components/EditNameDialog.tsx new file mode 100644 index 000000000..eaa7d4cb2 --- /dev/null +++ b/apps/web/src/pages/voice-clone/components/EditNameDialog.tsx @@ -0,0 +1,49 @@ +import React from "react" +import { Button } from "@/components/ui" + +interface EditNameDialogProps { + name: string + onNameChange: (name: string) => void + onConfirm: () => void + onCancel: () => void + loading?: boolean +} + +/** + * 编辑音色名称弹窗 + */ +export const EditNameDialog: React.FC = ({ + name, + onNameChange, + onConfirm, + onCancel, + loading, +}) => { + return ( +
+
e.stopPropagation()}> +

编辑音色名称

+ onNameChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onConfirm() + if (e.key === "Escape") onCancel() + }} + autoFocus + placeholder="输入音色名称" + /> +
+ + +
+
+
+ ) +} diff --git a/apps/web/src/pages/voice-clone/components/States.tsx b/apps/web/src/pages/voice-clone/components/States.tsx new file mode 100644 index 000000000..b19a0fc2c --- /dev/null +++ b/apps/web/src/pages/voice-clone/components/States.tsx @@ -0,0 +1,60 @@ +import React from "react" +import { AudioOutlined, CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons" +import { Button } from "@/components/ui" +import type { Toast } from "../types" + +interface VoiceCloneEmptyProps { + onClone: () => void +} + +/** 空状态 */ +export const VoiceCloneEmpty: React.FC = ({ onClone }) => ( +
+
+ +
+

还没有克隆音色

+

上传你的声音,AI将克隆你的专属音色

+ +
+) + +/** 骨架屏加载 */ +export const VoiceCloneSkeleton: React.FC = () => ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+) + +interface ToastContainerProps { + toasts: Toast[] +} + +/** Toast 提示容器 */ +export const ToastContainer: React.FC = ({ toasts }) => { + if (toasts.length === 0) return null + return ( +
+ {toasts.map((t) => ( +
+ {t.type === "success" ? : } {t.message} +
+ ))} +
+ ) +} diff --git a/apps/web/src/pages/voice-clone/components/VoiceCloneCard.tsx b/apps/web/src/pages/voice-clone/components/VoiceCloneCard.tsx new file mode 100644 index 000000000..ec89f2ecc --- /dev/null +++ b/apps/web/src/pages/voice-clone/components/VoiceCloneCard.tsx @@ -0,0 +1,111 @@ +import React from "react" +import { Tooltip } from "@/components/ui" +import { Popconfirm } from "antd" +import { + EditOutlined, + DeleteOutlined, + AudioOutlined, + SoundOutlined, + CalendarOutlined, +} from "@ant-design/icons" +import { Button } from "@/components/ui" +import { formatDuration, type VoiceClone } from "@/api/voice-clone" +import { STATUS_CONFIG } from "../types" + +interface VoiceCloneCardProps { + voice: VoiceClone + onPlay: (voice: VoiceClone) => void + onUse: (voice: VoiceClone) => void + onEdit: (voice: VoiceClone) => void + onDelete: (voice: VoiceClone) => void +} + +/** + * 音色克隆卡片 + */ +export const VoiceCloneCard: React.FC = ({ + voice, + onPlay, + onUse, + onEdit, + onDelete, +}) => { + const statusCfg = STATUS_CONFIG[voice.status] + const isProcessing = voice.status === "processing" + const createdDate = new Date(voice.created_at).toLocaleDateString("zh-CN") + + return ( +
+ {/* 右上角操作 */} +
+ + + + + onDelete(voice)} + okText="删除" + cancelText="取消" + > + + + +
+ + {/* 头部:头像 + 名称 + 状态 */} +
+
+ +
+
+

{voice.name}

+ + + {statusCfg.label} + +
+
+ + {/* 元信息 */} +
+
+ + + + 时长:{formatDuration(voice.duration_seconds)} +
+
+ + + + 创建于:{createdDate} +
+
+ + {/* 操作区 */} +
+ + +
+
+ ) +} diff --git a/apps/web/src/pages/voice-clone/hooks/useVoiceCloneList.ts b/apps/web/src/pages/voice-clone/hooks/useVoiceCloneList.ts new file mode 100644 index 000000000..ef295895d --- /dev/null +++ b/apps/web/src/pages/voice-clone/hooks/useVoiceCloneList.ts @@ -0,0 +1,157 @@ +import { useState, useCallback, useRef } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { + getVoiceClones, + deleteVoiceClone, + updateVoiceClone, + type VoiceClone, +} from "@/api/voice-clone" +import type { Toast } from "../types" + +let toastId = 0 + +/** + * 音色克隆列表业务 Hook + * 封装列表查询、删除、编辑、试听、Toast 等所有业务逻辑 + */ +export const useVoiceCloneList = () => { + const queryClient = useQueryClient() + const [toasts, setToasts] = useState([]) + const [editingVoice, setEditingVoice] = useState(null) + const [editName, setEditName] = useState("") + const [cloneModalOpen, setCloneModalOpen] = useState(false) + const audioRef = useRef(null) + + /** 显示 toast */ + const showToast = useCallback((message: string, type: Toast["type"]) => { + const id = ++toastId + setToasts((prev) => [...prev, { id, message, type }]) + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, 3000) + }, []) + + /** 查询克隆音色列表 */ + const { data: voices = [], isLoading } = useQuery({ + queryKey: ["voiceClones"], + queryFn: () => getVoiceClones(), + }) + + /** 删除 mutation */ + const deleteMutation = useMutation({ + mutationFn: deleteVoiceClone, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["voiceClones"] }) + showToast("音色已删除", "success") + }, + onError: () => { + showToast("删除失败", "error") + }, + }) + + /** 编辑 mutation */ + const updateMutation = useMutation({ + mutationFn: ({ id, name }: { id: string; name: string }) => updateVoiceClone(id, { name }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["voiceClones"] }) + setEditingVoice(null) + showToast("名称已更新", "success") + }, + onError: () => { + showToast("更新失败", "error") + }, + }) + + /** 克隆新音色 — 打开弹窗 */ + const handleCloneNew = useCallback(() => { + setCloneModalOpen(true) + }, []) + + /** 关闭克隆弹窗 */ + const handleCloseCloneModal = useCallback(() => { + setCloneModalOpen(false) + }, []) + + /** 克隆成功回调 */ + const handleCloneSuccess = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ["voiceClones"] }) + showToast("音色克隆已提交", "success") + }, [queryClient, showToast]) + + /** 试听 */ + const handlePlay = useCallback( + (voice: VoiceClone) => { + if (voice.sample_url) { + // 停止之前的播放 + if (audioRef.current) { + audioRef.current.pause() + audioRef.current = null + } + const audio = new Audio(voice.sample_url) + audioRef.current = audio + audio.play().catch(() => { + showToast("播放失败", "error") + }) + } else { + showToast("暂无试听音频", "error") + } + }, + [showToast], + ) + + /** 使用音色 */ + const handleUse = useCallback(() => { + showToast("已选择音色,跳转到生成页面", "success") + }, [showToast]) + + /** 打开编辑弹窗 */ + const handleEdit = useCallback((voice: VoiceClone) => { + setEditingVoice(voice) + setEditName(voice.name) + }, []) + + /** 关闭编辑弹窗 */ + const handleCloseEdit = useCallback(() => { + setEditingVoice(null) + }, []) + + /** 确认编辑 */ + const handleEditConfirm = useCallback(() => { + if (!editingVoice || !editName.trim()) return + updateMutation.mutate({ id: editingVoice.id, name: editName.trim() }) + }, [editingVoice, editName, updateMutation]) + + /** 删除确认 */ + const handleDelete = useCallback( + (voice: VoiceClone) => { + deleteMutation.mutate(voice.id) + }, + [deleteMutation], + ) + + return { + // 数据 + voices, + isLoading, + // 状态 + toasts, + editingVoice, + editName, + setEditName, + cloneModalOpen, + // 加载状态 + deleteLoading: deleteMutation.isPending, + updateLoading: updateMutation.isPending, + // 操作 + showToast, + handleCloneNew, + handleCloseCloneModal, + handleCloneSuccess, + handlePlay, + handleUse, + handleEdit, + handleCloseEdit, + handleEditConfirm, + handleDelete, + } +} diff --git a/apps/web/src/pages/voice-clone/types.ts b/apps/web/src/pages/voice-clone/types.ts new file mode 100644 index 000000000..857813334 --- /dev/null +++ b/apps/web/src/pages/voice-clone/types.ts @@ -0,0 +1,15 @@ +import type { VoiceClone } from "@/api/voice-clone" + +/** 状态配置 */ +export const STATUS_CONFIG: Record = { + ready: { label: "就绪", className: "vc-status-pill--ready" }, + processing: { label: "克隆中", className: "vc-status-pill--processing" }, + failed: { label: "失败", className: "vc-status-pill--failed" }, +} + +/** Toast 类型 */ +export interface Toast { + id: number + message: string + type: "success" | "error" +}