From 62139aae38dfb9f2f03f6d59a5d5b96e0ae33aed Mon Sep 17 00:00:00 2001 From: CI Test Date: Wed, 1 Jul 2026 14:54:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(task-3.12):=20=E6=88=91=E7=9A=84=E9=9F=B3?= =?UTF-8?q?=E8=89=B2=E9=A1=B5=E9=9D=A2=20-=20=E5=85=8B=E9=9A=86=E9=9F=B3?= =?UTF-8?q?=E8=89=B2=E5=8D=A1=E7=89=87=E5=88=97=E8=A1=A8=20+=20=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=AE=A1=E7=90=86=20+=20=E7=A9=BA=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/my-voices/MyVoices.tsx | 455 +++++++++++++++++++++ apps/web/src/pages/my-voices/my-voices.css | 398 ++++++++++++++++++ apps/web/src/router/index.tsx | 7 + 3 files changed, 860 insertions(+) create mode 100644 apps/web/src/pages/my-voices/MyVoices.tsx create mode 100644 apps/web/src/pages/my-voices/my-voices.css diff --git a/apps/web/src/pages/my-voices/MyVoices.tsx b/apps/web/src/pages/my-voices/MyVoices.tsx new file mode 100644 index 000000000..8d28c18f7 --- /dev/null +++ b/apps/web/src/pages/my-voices/MyVoices.tsx @@ -0,0 +1,455 @@ +/** + * 我的音色页面 — V21 设计系统(任务 3.12 升级) + * + * 功能:克隆音色卡片列表、试听播放、状态标签、删除、编辑名称、空状态引导 + * Mock 数据 + 预留 CosyVoice API 对接接口 + */ +import React, { useState, useCallback, useRef, useEffect } 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 { PageHead } from "@/components/layout/PageHead"; +import "./my-voices.css"; + +/* ============================================================ + * 类型 + * ============================================================ */ +type CloneStatus = "ready" | "processing" | "failed"; + +interface ClonedVoice { + id: string; + name: string; + sourceUrl: string; + status: CloneStatus; + createdAt: string; + durationSeconds: number; + sampleUrl: string; + progress: number; // 0-100 for processing +} + +/* ============================================================ + * CosyVoice API 预留接口(后续替换 Mock 数据) + * ============================================================ */ +async function fetchClonedVoices(): Promise { + // TODO: 替换为真实 API + // const res = await fetch("/api/v1/voice-clones"); + // return res.json(); + return new Promise((resolve) => setTimeout(() => resolve(MOCK_CLONED_VOICES), 300)); +} + +async function deleteClonedVoice(id: string): Promise { + // TODO: 替换为真实 API + // await fetch(`/api/v1/voice-clones/${id}`, { method: "DELETE" }); + console.log("Delete voice:", id); +} + +async function updateClonedVoiceName(id: string, name: string): Promise { + // TODO: 替换为真实 API + // await fetch(`/api/v1/voice-clones/${id}`, { method: "PUT", body: JSON.stringify({ name }) }); + console.log("Update voice name:", id, name); +} + +async function getVoicePreviewUrl(id: string): Promise { + // TODO: 替换为真实 API + return `/mock/audio/clone-${id}.mp3`; +} + +/* ============================================================ + * Mock 数据 + * ============================================================ */ +const MOCK_CLONED_VOICES: ClonedVoice[] = [ + { + id: "cv-1", + name: "我的播报音色", + sourceUrl: "/mock/audio/source-1.mp3", + status: "ready", + createdAt: "2026-06-15T10:30:00Z", + durationSeconds: 120, + sampleUrl: "/mock/audio/clone-1.mp3", + progress: 100, + }, + { + id: "cv-2", + name: "故事讲述风格", + sourceUrl: "/mock/audio/source-2.mp3", + status: "ready", + createdAt: "2026-06-20T14:22:00Z", + durationSeconds: 85, + sampleUrl: "/mock/audio/clone-2.mp3", + progress: 100, + }, + { + id: "cv-3", + name: "专业解说音色", + sourceUrl: "/mock/audio/source-3.mp3", + status: "processing", + createdAt: "2026-07-01T09:15:00Z", + durationSeconds: 0, + sampleUrl: "", + progress: 67, + }, + { + id: "cv-4", + name: "温柔引导声", + sourceUrl: "/mock/audio/source-4.mp3", + status: "failed", + createdAt: "2026-06-28T16:45:00Z", + durationSeconds: 0, + sampleUrl: "", + progress: 0, + }, +]; + +/* ============================================================ + * 工具函数 + * ============================================================ */ +function formatDuration(seconds: number): string { + if (seconds <= 0) return "--:--"; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, "0")}`; +} + +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: ClonedVoice; + isPlaying: boolean; + onTogglePlay: (voice: ClonedVoice) => void; + onEdit: (voice: ClonedVoice) => void; + onDelete: (voice: ClonedVoice) => 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.createdAt)} + + {voice.durationSeconds > 0 && ( + + {formatDuration(voice.durationSeconds)} + + )} +
+ + {/* 进度条(克隆中) */} + {voice.status === "processing" && ( +
+
+ {voice.progress}% +
+ )} + + {/* 操作区 */} +
+ {isReady ? ( + + ) : voice.status === "failed" ? ( + + ) : ( + + )} +
+ + + + + + +
+
+
+ ); +}; + +/* ============================================================ + * 主页面组件 + * ============================================================ */ +const MyVoices: React.FC = () => { + const navigate = useNavigate(); + const [voices, setVoices] = useState([]); + const [loading, setLoading] = useState(true); + 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); + + // 加载数据 + useEffect(() => { + fetchClonedVoices().then((data) => { + setVoices(data); + setLoading(false); + }); + }, []); + + // 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(async (voice: ClonedVoice) => { + if (playingId === voice.id) { + audioRef.current?.pause(); + setPlayingId(null); + return; + } + if (audioRef.current) { + audioRef.current.pause(); + } + const url = await getVoicePreviewUrl(voice.id); + const audio = new Audio(url); + audioRef.current = audio; + audio.play().catch(() => showToast("播放失败,请检查音频文件", "error")); + audio.onended = () => setPlayingId(null); + setPlayingId(voice.id); + }, [playingId, showToast]); + + // 编辑 + const handleEdit = (voice: ClonedVoice) => { + setEditingVoice(voice); + setEditName(voice.name); + setEditModalOpen(true); + }; + + const handleEditConfirm = async () => { + if (!editingVoice || !editName.trim()) return; + await updateClonedVoiceName(editingVoice.id, editName.trim()); + setVoices((prev) => + prev.map((v) => (v.id === editingVoice.id ? { ...v, name: editName.trim() } : v)) + ); + setEditModalOpen(false); + setEditingVoice(null); + showToast("名称已更新", "success"); + }; + + // 删除 + const handleDelete = (voice: ClonedVoice) => { + setDeleteConfirmId(voice.id); + }; + + const handleDeleteConfirm = async () => { + if (!deleteConfirmId) return; + await deleteClonedVoice(deleteConfirmId); + setVoices((prev) => prev.filter((v) => v.id !== deleteConfirmId)); + setDeleteConfirmId(null); + showToast("音色已删除", "success"); + }; + + // 克隆新音色 + const handleCloneNew = () => { + navigate("/app/voices"); + }; + + // 统计 + const readyCount = voices.filter((v) => v.status === "ready").length; + const processingCount = voices.filter((v) => v.status === "processing").length; + + return ( +
+ + 克隆新音色 + + } + /> + + {/* 统计栏 */} + {!loading && voices.length > 0 && ( +
+ + 共 {voices.length} 个音色 + + + + 就绪 {readyCount} + + {processingCount > 0 && ( + + + 克隆中 {processingCount} + + )} +
+ )} + + {/* 加载状态 */} + {loading && ( +
+
+

加载中...

+
+ )} + + {/* 卡片网格 */} + {!loading && voices.length > 0 && ( +
+ {voices.map((voice) => ( + + ))} +
+ )} + + {/* 空状态 */} + {!loading && voices.length === 0 && ( +
+
🎤
+

还没有克隆音色

+

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

+ +
+ )} + + {/* 编辑弹窗 */} + setEditModalOpen(false)} + onOk={handleEditConfirm} + okText="保存" + cancelText="取消" + > + ) => setEditName(e.target.value)} + placeholder="输入音色名称" + autoFocus + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") handleEditConfirm(); + }} + /> + + + {/* 删除确认弹窗 */} + setDeleteConfirmId(null)} + onOk={handleDeleteConfirm} + okText="删除" + cancelText="取消" + okButtonProps={{ danger: true } as any} + > +

确定要删除这个克隆音色吗?删除后无法恢复。

+
+ + {/* Toast */} + {toasts.length > 0 && ( +
+ {toasts.map((t) => ( +
+ {t.type === "success" ? "✅" : "❌"} {t.message} +
+ ))} +
+ )} +
+ ); +}; + +export default MyVoices; diff --git a/apps/web/src/pages/my-voices/my-voices.css b/apps/web/src/pages/my-voices/my-voices.css new file mode 100644 index 000000000..5eb833eba --- /dev/null +++ b/apps/web/src/pages/my-voices/my-voices.css @@ -0,0 +1,398 @@ +/* ═══════════════════════════════════════════════════════════ + 我的音色页面 — V21 Design System(任务 3.12) + 命名前缀:xx-mv- + ═══════════════════════════════════════════════════════════ */ + +/* ── 页面容器 ──────────────────────────────────────────── */ + +.xx-mv-page { + display: flex; + flex-direction: column; + gap: var(--space-md, 20px); +} + +/* ── 统计栏 ────────────────────────────────────────────── */ + +.xx-mv-stats { + display: flex; + align-items: center; + gap: var(--space-md, 16px); + padding: var(--space-sm, 12px) var(--space-md, 16px); + background: var(--bg-secondary, #f8fafc); + border-radius: var(--radius-md, 10px); + font-size: 13px; + color: var(--text-secondary, #64748b); +} + +.xx-mv-stat { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.xx-mv-stat strong { + color: var(--text-primary, #1e293b); + font-weight: 600; +} + +.xx-mv-stat-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; +} + +.xx-mv-stat--ready { + color: var(--secondary-color, #10b981); +} + +.xx-mv-stat--processing { + color: var(--accent-color, #f59e0b); +} + +/* ── 卡片网格 ──────────────────────────────────────────── */ + +.xx-mv-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: var(--space-md, 16px); +} + +/* ── 卡片 ──────────────────────────────────────────────── */ + +.xx-mv-card { + position: relative; + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: var(--radius-lg, 14px); + padding: var(--space-md, 20px); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.xx-mv-card:hover { + border-color: var(--color-primary-400, #818cf8); + box-shadow: 0 4px 20px color-mix(in srgb, var(--primary-color, #4f46e5) 8%, transparent); +} + +.xx-mv-card--processing { + border-color: color-mix(in srgb, var(--accent-color, #f59e0b) 30%, transparent); +} + +.xx-mv-card--failed { + border-color: color-mix(in srgb, var(--error-color, #ef4444) 25%, transparent); + opacity: 0.85; +} + +/* ── 卡片头部 ──────────────────────────────────────────── */ + +.xx-mv-card-header { + display: flex; + align-items: center; + gap: var(--space-sm, 14px); + margin-bottom: var(--space-sm, 14px); +} + +.xx-mv-card-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 20px; + color: var(--text-inverse, #fff); + flex-shrink: 0; + background: linear-gradient( + 135deg, + var(--secondary-color, #10b981), + var(--color-secondary-700, #059669) + ); +} + +.xx-mv-card-avatar--processing { + background: linear-gradient( + 135deg, + var(--accent-color, #f59e0b), + var(--accent-dark, #d97706) + ); + animation: xx-mv-pulse 2s ease-in-out infinite; +} + +.xx-mv-card-avatar--failed { + background: linear-gradient( + 135deg, + var(--color-gray-400, #94a3b8), + var(--color-gray-500, #64748b) + ); +} + +@keyframes xx-mv-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.xx-mv-card-info { + flex: 1; + min-width: 0; +} + +.xx-mv-card-name { + margin: 0 0 6px; + font-size: 15px; + font-weight: 600; + color: var(--text-primary, #1e293b); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── 状态标签 ──────────────────────────────────────────── */ + +.xx-mv-status { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary, #64748b); +} + +.xx-mv-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; +} + +.xx-mv-status-dot--ready { + color: var(--secondary-color, #10b981); +} + +.xx-mv-status-dot--processing { + color: var(--accent-color, #f59e0b); + animation: xx-mv-blink 1.5s ease-in-out infinite; +} + +.xx-mv-status-dot--failed { + color: var(--error-color, #ef4444); +} + +@keyframes xx-mv-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* ── 元信息 ────────────────────────────────────────────── */ + +.xx-mv-card-meta { + display: flex; + align-items: center; + gap: var(--space-sm, 12px); + margin-bottom: var(--space-sm, 14px); + font-size: 13px; + color: var(--text-secondary, #64748b); +} + +.xx-mv-card-meta-item { + display: inline-flex; + align-items: center; + gap: 4px; +} + +/* ── 进度条 ────────────────────────────────────────────── */ + +.xx-mv-progress { + position: relative; + height: 6px; + background: var(--bg-tertiary, #f1f5f9); + border-radius: 3px; + margin-bottom: var(--space-sm, 14px); + overflow: hidden; +} + +.xx-mv-progress-bar { + height: 100%; + background: linear-gradient( + 90deg, + var(--accent-color, #f59e0b), + var(--secondary-color, #10b981) + ); + border-radius: 3px; + transition: width 0.5s ease; +} + +.xx-mv-progress-text { + position: absolute; + right: 0; + top: -18px; + font-size: 11px; + color: var(--accent-color, #f59e0b); + font-weight: 600; +} + +/* ── 操作区 ────────────────────────────────────────────── */ + +.xx-mv-card-actions { + display: flex; + align-items: center; + gap: var(--space-sm, 10px); + padding-top: var(--space-sm, 12px); + border-top: 1px solid var(--border-color, #e2e8f0); +} + +.xx-mv-card-actions .xx-btn { + flex: 1; +} + +.xx-mv-card-icon-actions { + display: flex; + gap: 4px; +} + +.xx-mv-icon-btn { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border: none; + border-radius: var(--radius-sm, 8px); + background: transparent; + color: var(--text-secondary, #64748b); + font-size: 14px; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.xx-mv-icon-btn:hover { + background: var(--bg-secondary, #f8fafc); + color: var(--text-primary, #1e293b); +} + +.xx-mv-icon-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.xx-mv-icon-btn--danger:hover { + background: var(--error-soft, #fef2f2); + color: var(--error-color, #ef4444); +} + +/* ── 空状态 ────────────────────────────────────────────── */ + +.xx-mv-empty { + margin-top: var(--space-lg, 40px); + text-align: center; + padding: 60px var(--space-md, 24px); + background: var(--bg-card, #fff); + border: 1px dashed var(--border-color, #e2e8f0); + border-radius: var(--radius-lg, 14px); +} + +.xx-mv-empty-icon { + font-size: 64px; + margin-bottom: var(--space-md, 16px); + line-height: 1; +} + +.xx-mv-empty-title { + margin: 0 0 var(--space-sm, 8px); + font-size: 18px; + font-weight: 600; + color: var(--text-primary, #1e293b); +} + +.xx-mv-empty-desc { + margin: 0 0 var(--space-md, 24px); + font-size: 14px; + color: var(--text-secondary, #64748b); +} + +/* ── Toast ─────────────────────────────────────────────── */ + +.xx-mv-toast-container { + position: fixed; + top: 80px; + right: 20px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; +} + +.xx-mv-toast { + padding: 10px 16px; + border-radius: var(--radius-md, 10px); + font-size: 13px; + font-weight: 500; + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e2e8f0); + box-shadow: 0 4px 12px var(--shadow-sm, rgba(0, 0, 0, 0.08)); + animation: xx-mv-toast-in 0.25s ease-out; + display: flex; + align-items: center; + gap: 8px; +} + +.xx-mv-toast--success { + border-color: var(--secondary-color, #10b981); + color: var(--secondary-color, #10b981); +} + +.xx-mv-toast--error { + border-color: var(--error-color, #ef4444); + color: var(--error-color, #ef4444); +} + +@keyframes xx-mv-toast-in { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* ── 响应式 ────────────────────────────────────────────── */ + +@media (max-width: 1200px) { + .xx-mv-grid { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } +} + +@media (max-width: 768px) { + .xx-mv-grid { + grid-template-columns: 1fr; + } + + .xx-mv-stats { + flex-wrap: wrap; + gap: var(--space-sm, 8px); + } + + .xx-mv-empty { + padding: 40px var(--space-md, 16px); + } + + .xx-mv-empty-icon { + font-size: 48px; + } +} + +@media (max-width: 480px) { + .xx-mv-card { + padding: var(--space-sm, 14px); + } + + .xx-mv-card-avatar { + width: 40px; + height: 40px; + font-size: 17px; + } + + .xx-mv-card-actions { + flex-wrap: wrap; + } +} diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx index 1f6430271..dbbc0aa6a 100644 --- a/apps/web/src/router/index.tsx +++ b/apps/web/src/router/index.tsx @@ -147,6 +147,13 @@ export const router = createBrowserRouter([ import("@/pages/voice-clone/VoiceClone").then((m) => ({ Component: m.default, })), + { + path: "my-voices", + lazy: () => + import("@/pages/my-voices/MyVoices").then((m) => ({ + Component: m.default, + })), + }, }, { path: "accounts", -- 2.54.0