From 5d3c4a8e21fe7062d756233ef1fc6651c43438fd Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 17:52:24 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor(my-voices):=20=E4=B8=89=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E9=87=8D=E6=9E=84=20MyVoices=EF=BC=88381=E2=86=92148?= =?UTF-8?q?=E8=A1=8C,=20-61%=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 1: 抽 types.ts(STATUS_CONFIG/ToastItem)+ utils.ts(formatDate) - Phase 2: 抽 3 个 UI 组件(VoiceCard/States(5个状态组件)) - Phase 3: 抽 useMyVoices 业务 Hook(播放/编辑/删除/Toast) - 主文件 381→148 行,逻辑与视图分离 --- apps/web/src/pages/my-voices/MyVoices.tsx | 336 +++--------------- .../src/pages/my-voices/components/States.tsx | 79 ++++ .../pages/my-voices/components/VoiceCard.tsx | 121 +++++++ .../src/pages/my-voices/hooks/useMyVoices.ts | 140 ++++++++ apps/web/src/pages/my-voices/types.ts | 15 + apps/web/src/pages/my-voices/utils.ts | 9 + 6 files changed, 406 insertions(+), 294 deletions(-) mode change 100644 => 100755 apps/web/src/pages/my-voices/MyVoices.tsx create mode 100644 apps/web/src/pages/my-voices/components/States.tsx create mode 100644 apps/web/src/pages/my-voices/components/VoiceCard.tsx create mode 100644 apps/web/src/pages/my-voices/hooks/useMyVoices.ts create mode 100644 apps/web/src/pages/my-voices/types.ts create mode 100644 apps/web/src/pages/my-voices/utils.ts diff --git a/apps/web/src/pages/my-voices/MyVoices.tsx b/apps/web/src/pages/my-voices/MyVoices.tsx old mode 100644 new mode 100755 index f2a261cb8..b1bf5e801 --- a/apps/web/src/pages/my-voices/MyVoices.tsx +++ b/apps/web/src/pages/my-voices/MyVoices.tsx @@ -1,259 +1,48 @@ /** - * 我的音色页面 — V21 设计系统(任务 3.12 升级 / 3.15 进度轮询) + * 我的音色页面 — V21 设计系统 * * 功能:克隆音色卡片列表、试听播放、状态标签、删除、编辑名称、空状态引导 * 使用 useCloneProgress hook 实现 processing 状态自动轮询 */ -import React, { useState, useCallback, useRef } from "react" -import { useNavigate } from "react-router-dom" -import { - PlayCircleOutlined, - PauseCircleOutlined, - DeleteOutlined, - EditOutlined, - PlusOutlined, - SoundOutlined, - ClockCircleOutlined, -} from "@ant-design/icons" -import { Button, Modal, Input, Tooltip } from "@/components/ui" +import React from "react" +import { PlusOutlined } from "@ant-design/icons" +import { Button, Modal, Input } from "@/components/ui" import type { ButtonProps } from "antd" import PageHead from "@/components/layout/PageHead" -import { useCloneProgress } from "@/hooks/useCloneProgress" -import { deleteVoiceClone, updateVoiceClone, formatDuration } from "@/api/voice-clone" -import type { VoiceClone, VoiceCloneStatus } from "@/api/voice-clone" +import { VoiceCard } from "./components/VoiceCard" +import { + StatsBar, + EmptyState, + LoadingState, + PollingHint, + ToastContainer, +} from "./components/States" +import { useMyVoices } from "./hooks/useMyVoices" import "./my-voices.css" -/* ============================================================ - * 工具函数 - * ============================================================ */ -function formatDate(isoStr: string): string { - const d = new Date(isoStr) - return d.toLocaleDateString("zh-CN", { - year: "numeric", - month: "2-digit", - day: "2-digit", - }) -} - -/* ============================================================ - * 状态配置 - * ============================================================ */ -const STATUS_CONFIG: Record = { - ready: { label: "就绪", dotClass: "xx-mv-status-dot--ready" }, - processing: { label: "克隆中", dotClass: "xx-mv-status-dot--processing" }, - failed: { label: "失败", dotClass: "xx-mv-status-dot--failed" }, -} - -/* ============================================================ - * Toast 组件 - * ============================================================ */ -interface ToastItem { - id: number - message: string - type: "success" | "error" -} -let _toastId = 0 - -/* ============================================================ - * 音色卡片组件 - * ============================================================ */ -interface VoiceCardProps { - voice: VoiceClone - isPlaying: boolean - onTogglePlay: (voice: VoiceClone) => void - onEdit: (voice: VoiceClone) => void - onDelete: (voice: VoiceClone) => void -} - -const VoiceCard: React.FC = ({ - voice, - isPlaying, - onTogglePlay, - onEdit, - onDelete, -}) => { - const statusCfg = STATUS_CONFIG[voice.status] - const isReady = voice.status === "ready" - - return ( -
- {/* 头部:头像 + 名称 + 状态 */} -
-
- -
-
-

{voice.name}

- - - {statusCfg.label} - -
-
- - {/* 元信息 */} -
- - {formatDate(voice.created_at)} - - {voice.duration_seconds > 0 && ( - {formatDuration(voice.duration_seconds)} - )} -
- - {/* 进度条(克隆中 — indeterminate 条纹流动动画) */} - {voice.status === "processing" && ( -
-
- 处理中… -
- )} - - {/* 操作区 */} -
- {isReady ? ( - - ) : voice.status === "failed" ? ( - - ) : ( - - )} -
- - - - - - -
-
-
- ) -} - -/* ============================================================ - * 主页面组件 - * ============================================================ */ const MyVoices: React.FC = () => { - const navigate = useNavigate() - const { clones, loading, removeClone, updateClone, hasProcessing } = useCloneProgress() - const [playingId, setPlayingId] = useState(null) - const [toasts, setToasts] = useState([]) - const [editModalOpen, setEditModalOpen] = useState(false) - const [editingVoice, setEditingVoice] = useState(null) - const [editName, setEditName] = useState("") - const [deleteConfirmId, setDeleteConfirmId] = useState(null) - const audioRef = useRef(null) - - // Toast - const showToast = useCallback((message: string, type: ToastItem["type"]) => { - const id = ++_toastId - setToasts((prev) => [...prev, { id, message, type }]) - setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000) - }, []) - - // 试听播放 - const handleTogglePlay = useCallback( - (voice: VoiceClone) => { - if (playingId === voice.id) { - audioRef.current?.pause() - setPlayingId(null) - return - } - if (audioRef.current) { - audioRef.current.pause() - } - if (!voice.sample_url) { - showToast("暂无试听音频", "error") - return - } - const audio = new Audio(voice.sample_url) - audioRef.current = audio - audio.play().catch(() => showToast("播放失败,请检查音频文件", "error")) - audio.onended = () => setPlayingId(null) - setPlayingId(voice.id) - }, - [playingId, showToast], - ) - - // 编辑 - const handleEdit = (voice: VoiceClone) => { - setEditingVoice(voice) - setEditName(voice.name) - setEditModalOpen(true) - } - - const handleEditConfirm = async () => { - if (!editingVoice || !editName.trim()) return - try { - const updated = await updateVoiceClone(editingVoice.id, { - name: editName.trim(), - }) - updateClone(updated) - setEditModalOpen(false) - setEditingVoice(null) - showToast("名称已更新", "success") - } catch { - showToast("更新失败,请重试", "error") - } - } - - // 删除 - const handleDelete = (voice: VoiceClone) => { - setDeleteConfirmId(voice.id) - } - - const handleDeleteConfirm = async () => { - if (!deleteConfirmId) return - try { - await deleteVoiceClone(deleteConfirmId) - removeClone(deleteConfirmId) - setDeleteConfirmId(null) - showToast("音色已删除", "success") - } catch { - showToast("删除失败,请重试", "error") - } - } - - // 克隆新音色 - const handleCloneNew = () => { - navigate("/app/voices") - } - - // 统计 - const readyCount = clones.filter((v) => v.status === "ready").length - const processingCount = clones.filter((v) => v.status === "processing").length + const { + clones, + loading, + hasProcessing, + playingId, + toasts, + editModalOpen, + editingVoice, + editName, + setEditName, + deleteConfirmId, + readyCount, + processingCount, + handleTogglePlay, + handleEdit, + handleEditCancel, + handleEditConfirm, + handleDelete, + handleDeleteCancel, + handleDeleteConfirm, + handleCloneNew, + } = useMyVoices() return (
@@ -269,39 +58,15 @@ const MyVoices: React.FC = () => { /> {/* 轮询提示 */} - {hasProcessing && ( -
- - 正在同步克隆进度... -
- )} + {hasProcessing && } {/* 统计栏 */} {!loading && clones.length > 0 && ( -
- - 共 {clones.length} 个音色 - - - - 就绪 {readyCount} - - {processingCount > 0 && ( - - - 克隆中 {processingCount} - - )} -
+ )} {/* 加载状态 */} - {loading && ( -
-
-

加载中...

-
- )} + {loading && } {/* 卡片网格 */} {!loading && clones.length > 0 && ( @@ -320,22 +85,13 @@ const MyVoices: React.FC = () => { )} {/* 空状态 */} - {!loading && clones.length === 0 && ( -
-
🎤
-

还没有克隆音色

-

上传你的声音样本,AI 将克隆生成你的专属音色

- -
- )} + {!loading && clones.length === 0 && } {/* 编辑弹窗 */} setEditModalOpen(false)} + onCancel={handleEditCancel} onOk={handleEditConfirm} okText="保存" cancelText="取消" @@ -355,7 +111,7 @@ const MyVoices: React.FC = () => { setDeleteConfirmId(null)} + onCancel={handleDeleteCancel} onOk={handleDeleteConfirm} okText="删除" cancelText="取消" @@ -365,15 +121,7 @@ const MyVoices: React.FC = () => { {/* Toast */} - {toasts.length > 0 && ( -
- {toasts.map((t) => ( -
- {t.type === "success" ? "✅" : "❌"} {t.message} -
- ))} -
- )} +
) } diff --git a/apps/web/src/pages/my-voices/components/States.tsx b/apps/web/src/pages/my-voices/components/States.tsx new file mode 100644 index 000000000..7b7e5621d --- /dev/null +++ b/apps/web/src/pages/my-voices/components/States.tsx @@ -0,0 +1,79 @@ +import React from "react" +import { PlusOutlined } from "@ant-design/icons" +import { Button } from "@/components/ui" +import type { ToastItem } from "../types" + +interface StatsBarProps { + total: number + readyCount: number + processingCount: number +} + +/** 统计栏 */ +export const StatsBar: React.FC = ({ total, readyCount, processingCount }) => ( +
+ + 共 {total} 个音色 + + + + 就绪 {readyCount} + + {processingCount > 0 && ( + + + 克隆中 {processingCount} + + )} +
+) + +interface EmptyStateProps { + onClone: () => void +} + +/** 空状态 */ +export const EmptyState: React.FC = ({ onClone }) => ( +
+
🎤
+

还没有克隆音色

+

上传你的声音样本,AI 将克隆生成你的专属音色

+ +
+) + +/** 加载状态 */ +export const LoadingState: React.FC = () => ( +
+
+

加载中...

+
+) + +/** 轮询提示 */ +export const PollingHint: React.FC = () => ( +
+ + 正在同步克隆进度... +
+) + +interface ToastContainerProps { + toasts: ToastItem[] +} + +/** 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/my-voices/components/VoiceCard.tsx b/apps/web/src/pages/my-voices/components/VoiceCard.tsx new file mode 100644 index 000000000..78e561075 --- /dev/null +++ b/apps/web/src/pages/my-voices/components/VoiceCard.tsx @@ -0,0 +1,121 @@ +import React from "react" +import { + PlayCircleOutlined, + PauseCircleOutlined, + DeleteOutlined, + EditOutlined, + SoundOutlined, + ClockCircleOutlined, +} from "@ant-design/icons" +import { Button, Tooltip } from "@/components/ui" +import { formatDuration, type VoiceClone } from "@/api/voice-clone" +import { STATUS_CONFIG } from "../types" +import { formatDate } from "../utils" + +interface VoiceCardProps { + voice: VoiceClone + isPlaying: boolean + onTogglePlay: (voice: VoiceClone) => void + onEdit: (voice: VoiceClone) => void + onDelete: (voice: VoiceClone) => void +} + +/** + * 音色卡片组件 + */ +export const VoiceCard: React.FC = ({ + voice, + isPlaying, + onTogglePlay, + onEdit, + onDelete, +}) => { + const statusCfg = STATUS_CONFIG[voice.status] + const isReady = voice.status === "ready" + + return ( +
+ {/* 头部:头像 + 名称 + 状态 */} +
+
+ +
+
+

{voice.name}

+ + + {statusCfg.label} + +
+
+ + {/* 元信息 */} +
+ + {formatDate(voice.created_at)} + + {voice.duration_seconds > 0 && ( + {formatDuration(voice.duration_seconds)} + )} +
+ + {/* 进度条(克隆中 — indeterminate 条纹流动动画) */} + {voice.status === "processing" && ( +
+
+ 处理中… +
+ )} + + {/* 操作区 */} +
+ {isReady ? ( + + ) : voice.status === "failed" ? ( + + ) : ( + + )} +
+ + + + + + +
+
+
+ ) +} diff --git a/apps/web/src/pages/my-voices/hooks/useMyVoices.ts b/apps/web/src/pages/my-voices/hooks/useMyVoices.ts new file mode 100644 index 000000000..e45f17852 --- /dev/null +++ b/apps/web/src/pages/my-voices/hooks/useMyVoices.ts @@ -0,0 +1,140 @@ +import { useState, useCallback, useRef } from "react" +import { useNavigate } from "react-router-dom" +import { useCloneProgress } from "@/hooks/useCloneProgress" +import { deleteVoiceClone, updateVoiceClone } from "@/api/voice-clone" +import type { VoiceClone } from "@/api/voice-clone" +import type { ToastItem } from "../types" + +let _toastId = 0 + +/** + * 我的音色业务 Hook + * 封装播放、编辑、删除、Toast 等业务逻辑 + */ +export const useMyVoices = () => { + const navigate = useNavigate() + const { clones, loading, removeClone, updateClone, hasProcessing } = useCloneProgress() + const [playingId, setPlayingId] = useState(null) + const [toasts, setToasts] = useState([]) + const [editModalOpen, setEditModalOpen] = useState(false) + const [editingVoice, setEditingVoice] = useState(null) + const [editName, setEditName] = useState("") + const [deleteConfirmId, setDeleteConfirmId] = useState(null) + const audioRef = useRef(null) + + // Toast + const showToast = useCallback((message: string, type: ToastItem["type"]) => { + const id = ++_toastId + setToasts((prev) => [...prev, { id, message, type }]) + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000) + }, []) + + // 试听播放 + const handleTogglePlay = useCallback( + (voice: VoiceClone) => { + if (playingId === voice.id) { + audioRef.current?.pause() + setPlayingId(null) + return + } + if (audioRef.current) { + audioRef.current.pause() + } + if (!voice.sample_url) { + showToast("暂无试听音频", "error") + return + } + const audio = new Audio(voice.sample_url) + audioRef.current = audio + audio.play().catch(() => showToast("播放失败,请检查音频文件", "error")) + audio.onended = () => setPlayingId(null) + setPlayingId(voice.id) + }, + [playingId, showToast], + ) + + // 编辑 + const handleEdit = useCallback((voice: VoiceClone) => { + setEditingVoice(voice) + setEditName(voice.name) + setEditModalOpen(true) + }, []) + + const handleEditCancel = useCallback(() => { + setEditModalOpen(false) + setEditingVoice(null) + }, []) + + const handleEditConfirm = useCallback(async () => { + if (!editingVoice || !editName.trim()) return + try { + const updated = await updateVoiceClone(editingVoice.id, { + name: editName.trim(), + }) + updateClone(updated) + setEditModalOpen(false) + setEditingVoice(null) + showToast("名称已更新", "success") + } catch { + showToast("更新失败,请重试", "error") + } + }, [editingVoice, editName, updateClone, showToast]) + + // 删除 + const handleDelete = useCallback((voice: VoiceClone) => { + setDeleteConfirmId(voice.id) + }, []) + + const handleDeleteCancel = useCallback(() => { + setDeleteConfirmId(null) + }, []) + + const handleDeleteConfirm = useCallback(async () => { + if (!deleteConfirmId) return + try { + await deleteVoiceClone(deleteConfirmId) + removeClone(deleteConfirmId) + setDeleteConfirmId(null) + showToast("音色已删除", "success") + } catch { + showToast("删除失败,请重试", "error") + } + }, [deleteConfirmId, removeClone, showToast]) + + // 克隆新音色 + const handleCloneNew = useCallback(() => { + navigate("/app/voices") + }, [navigate]) + + // 统计 + const readyCount = clones.filter((v) => v.status === "ready").length + const processingCount = clones.filter((v) => v.status === "processing").length + + return { + // 数据 + clones, + loading, + hasProcessing, + // 状态 + playingId, + toasts, + editModalOpen, + editingVoice, + editName, + setEditName, + deleteConfirmId, + // 统计 + readyCount, + processingCount, + // 操作 + showToast, + handleTogglePlay, + handleEdit, + handleEditCancel, + handleEditConfirm, + handleDelete, + handleDeleteCancel, + handleDeleteConfirm, + handleCloneNew, + } +} diff --git a/apps/web/src/pages/my-voices/types.ts b/apps/web/src/pages/my-voices/types.ts new file mode 100644 index 000000000..88c8073f4 --- /dev/null +++ b/apps/web/src/pages/my-voices/types.ts @@ -0,0 +1,15 @@ +import type { VoiceCloneStatus } from "@/api/voice-clone" + +/** 状态配置 */ +export const STATUS_CONFIG: Record = { + ready: { label: "就绪", dotClass: "xx-mv-status-dot--ready" }, + processing: { label: "克隆中", dotClass: "xx-mv-status-dot--processing" }, + failed: { label: "失败", dotClass: "xx-mv-status-dot--failed" }, +} + +/** Toast 类型 */ +export interface ToastItem { + id: number + message: string + type: "success" | "error" +} diff --git a/apps/web/src/pages/my-voices/utils.ts b/apps/web/src/pages/my-voices/utils.ts new file mode 100644 index 000000000..429cdee04 --- /dev/null +++ b/apps/web/src/pages/my-voices/utils.ts @@ -0,0 +1,9 @@ +/** 格式化日期 */ +export function formatDate(isoStr: string): string { + const d = new Date(isoStr) + return d.toLocaleDateString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }) +} -- 2.54.0 From c45a845df4b395a5dddaa99b09b2d081ce64f2b0 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 18:31:27 +0800 Subject: [PATCH 2/3] fix: remove unused editingVoice variable --- apps/web/src/pages/my-voices/MyVoices.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/pages/my-voices/MyVoices.tsx b/apps/web/src/pages/my-voices/MyVoices.tsx index b1bf5e801..632aefb1b 100755 --- a/apps/web/src/pages/my-voices/MyVoices.tsx +++ b/apps/web/src/pages/my-voices/MyVoices.tsx @@ -28,7 +28,6 @@ const MyVoices: React.FC = () => { playingId, toasts, editModalOpen, - editingVoice, editName, setEditName, deleteConfirmId, @@ -126,4 +125,4 @@ const MyVoices: React.FC = () => { ) } -export default MyVoices +export default MyVoices \ No newline at end of file -- 2.54.0 From 797e9df48f26e03d75e07985d13e35ad83da1362 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 19:03:59 +0800 Subject: [PATCH 3/3] style: prettier format MyVoices.tsx --- apps/web/src/pages/my-voices/MyVoices.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/my-voices/MyVoices.tsx b/apps/web/src/pages/my-voices/MyVoices.tsx index 632aefb1b..14b602981 100755 --- a/apps/web/src/pages/my-voices/MyVoices.tsx +++ b/apps/web/src/pages/my-voices/MyVoices.tsx @@ -125,4 +125,4 @@ const MyVoices: React.FC = () => { ) } -export default MyVoices \ No newline at end of file +export default MyVoices -- 2.54.0