diff --git a/apps/web/src/api/voiceClone.ts b/apps/web/src/api/voiceClone.ts new file mode 100644 index 000000000..11e6d9633 --- /dev/null +++ b/apps/web/src/api/voiceClone.ts @@ -0,0 +1,127 @@ +/** + * 音色克隆 API + * Sprint 4 — mock 数据,后续迁移真实 API + */ + +/** 克隆音色状态 */ +export type VoiceCloneStatus = "ready" | "processing" | "failed"; + +/** 克隆音色条目 */ +export interface VoiceClone { + id: string; + name: string; + duration_seconds: number; + status: VoiceCloneStatus; + sample_url?: string; + created_at: string; + updated_at: string; +} + +/** 创建克隆请求 */ +export interface CreateVoiceCloneRequest { + name: string; + audio_url: string; +} + +/* ── Mock 数据 ─────────────────────────────────────────── */ + +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", + }, +]; + +/* ── 辅助函数 ─────────────────────────────────────────── */ + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** 格式化时长 */ +export const formatDuration = (seconds: number): string => { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${String(s).padStart(2, "0")}`; +}; + +/* ── API 函数 ─────────────────────────────────────────── */ + +/** 获取所有克隆音色 */ +export const getVoiceClones = async (): Promise => { + await delay(300); + return [...MOCK_CLONES]; +}; + +/** 获取单个克隆音色详情 */ +export const getVoiceCloneDetail = async ( + id: string, +): Promise => { + await delay(200); + return MOCK_CLONES.find((v) => v.id === id) ?? null; +}; + +/** 创建克隆音色 */ +export const createVoiceClone = async ( + data: CreateVoiceCloneRequest, +): Promise => { + await delay(500); + const newClone: VoiceClone = { + id: `vc-${Date.now()}`, + name: data.name, + duration_seconds: 0, + status: "processing", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + MOCK_CLONES.unshift(newClone); + return newClone; +}; + +/** 删除克隆音色 */ +export const deleteVoiceClone = async (id: string): Promise => { + await delay(300); + const idx = MOCK_CLONES.findIndex((v) => v.id === id); + if (idx >= 0) MOCK_CLONES.splice(idx, 1); +}; + +/** 更新克隆音色名称 */ +export const updateVoiceClone = async ( + id: string, + data: Partial>, +): Promise => { + 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 }; +}; diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index 2e3d3afd7..f0b9257e2 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -51,6 +51,12 @@ const NAV_ITEMS: NavItem[] = [ icon: , }, { key: "voices", label: "配音库", path: "/voices", icon: }, + { + key: "voice-clone", + label: "我的音色", + path: "/voice-clone", + icon: , + }, { key: "templates", label: "模板库", diff --git a/apps/web/src/components/layout/PageHead.tsx b/apps/web/src/components/layout/PageHead.tsx index 191e4e685..c658ca73e 100644 --- a/apps/web/src/components/layout/PageHead.tsx +++ b/apps/web/src/components/layout/PageHead.tsx @@ -61,6 +61,7 @@ const ROUTE_TITLE_MAP: Record = { "/profile": "个人设置", "/editing-planner": "剪辑规划", "/my-templates": "我的模板", + "/voice-clone": "我的音色", "/duplication": "查重", "/duplication/results": "查重结果", }; diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 84dd32400..f082876ca 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -74,6 +74,12 @@ const MENU_GROUPS: SidebarMenuGroup[] = [ path: "/voices", icon: , }, + { + key: "voice-clone", + label: "我的音色", + path: "/voice-clone", + icon: , + }, { key: "titles", label: "标题库", diff --git a/apps/web/src/pages/voice-clone/VoiceClone.tsx b/apps/web/src/pages/voice-clone/VoiceClone.tsx new file mode 100644 index 000000000..e53414b6d --- /dev/null +++ b/apps/web/src/pages/voice-clone/VoiceClone.tsx @@ -0,0 +1,324 @@ +/** + * 我的音色页面 — V21 Design System + * + * 展示克隆音色列表,卡片网格布局 + * 支持试听、使用、编辑名称、删除操作 + */ +import React, { useState, useCallback } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button, Tooltip } from "@/components/ui"; +import PageHead from "@/components/layout/PageHead"; +import { + getVoiceClones, + deleteVoiceClone, + updateVoiceClone, + formatDuration, + type VoiceClone as VoiceCloneType, +} from "@/api/voiceClone"; +import "./voice-clone.css"; + +/* ── 状态配置 ─────────────────────────────────────────── */ + +const STATUS_CONFIG: Record< + VoiceCloneType["status"], + { label: string; className: string } +> = { + 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 ( +
+ {/* 右上角操作 */} +
+ + + + + + +
+ + {/* 头部:头像 + 名称 + 状态 */} +
+
+ 🎤 +
+
+

{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(""); + + /** 显示 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"); + }, + }); + + /** 克隆新音色 — 预留入口(Task 1.18 实现弹窗) */ + const handleCloneNew = () => { + showToast("克隆音色功能即将上线", "success"); + }; + + /** 试听 */ + 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() }); + }; + + /** 删除确认 */ + const handleDelete = (voice: VoiceCloneType) => { + if (window.confirm(`确定删除音色「${voice.name}」吗?`)) { + deleteMutation.mutate(voice.id); + } + }; + + return ( +
+ + ✨ 克隆新音色 + + } + /> + + {/* 加载状态 */} + {isLoading && ( +
+
+

加载中...

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

还没有克隆音色

+

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

+ +
+ )} + + {/* 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="输入音色名称" + /> +
+ + +
+
+
+ )} +
+ ); +}; + +export default VoiceClone; diff --git a/apps/web/src/pages/voice-clone/voice-clone.css b/apps/web/src/pages/voice-clone/voice-clone.css new file mode 100644 index 000000000..8e33301cb --- /dev/null +++ b/apps/web/src/pages/voice-clone/voice-clone.css @@ -0,0 +1,363 @@ +/* ═══════════════════════════════════════════════════════════ + 我的音色页面 — V21 Design System + ═══════════════════════════════════════════════════════════ */ + +/* ── 页面容器 ───────────────────────────────────────────── */ + +.vc-page { + display: flex; + flex-direction: column; + gap: 20px; +} + +/* ── 卡片网格 ───────────────────────────────────────────── */ + +.vc-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 16px; + margin-top: 4px; +} + +/* ── 卡片 ───────────────────────────────────────────────── */ + +.vc-card { + position: relative; + background: var(--bg-card, #fff); + border: 1px solid var(--line, #e2e8f0); + border-radius: var(--radius-lg, 14px); + padding: 20px; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.vc-card:hover { + border-color: var(--primary-light, #6366f1); + box-shadow: 0 4px 20px rgba(99, 102, 241, 0.08); +} + +/* 右上角操作按钮 */ +.vc-card-actions { + position: absolute; + top: 14px; + right: 14px; + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.2s; +} + +.vc-card:hover .vc-card-actions { + opacity: 1; +} + +.vc-card-action-btn { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border: none; + border-radius: var(--radius-sm, 8px); + background: var(--bg-secondary, #f8fafc); + color: var(--muted, #64748b); + font-size: 13px; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.vc-card-action-btn:hover { + background: var(--bg-hover, #f1f5f9); + color: var(--text-primary, #1e293b); +} + +.vc-card-action-btn--danger:hover { + background: var(--error-bg, #fef2f2); + color: var(--error, #ef4444); +} + +/* 卡片头部:头像 + 名称 + 状态 */ +.vc-card-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 14px; +} + +.vc-card-avatar { + width: 52px; + height: 52px; + border-radius: 50%; + background: linear-gradient(135deg, #f59e0b, #d97706); + display: grid; + place-items: center; + font-size: 22px; + color: #fff; + flex-shrink: 0; +} + +.vc-card-avatar--processing { + background: linear-gradient(135deg, #94a3b8, #64748b); + animation: vc-pulse 2s ease-in-out infinite; +} + +@keyframes vc-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.vc-card-info { + flex: 1; + min-width: 0; +} + +.vc-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; +} + +/* 状态胶囊 */ +.vc-status-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 10px; + border-radius: 20px; + font-size: 12px; + font-weight: 500; + line-height: 20px; +} + +.vc-status-pill--ready { + background: var(--success-bg, #f0fdf4); + color: var(--success, #22c55e); +} + +.vc-status-pill--processing { + background: var(--warning-bg, #fffbeb); + color: var(--warning, #f59e0b); +} + +.vc-status-pill--failed { + background: var(--error-bg, #fef2f2); + color: var(--error, #ef4444); +} + +.vc-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.vc-status-pill--processing .vc-status-dot { + animation: vc-blink 1.5s ease-in-out infinite; +} + +@keyframes vc-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* 元信息 */ +.vc-card-meta { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 14px; + font-size: 13px; + color: var(--muted, #64748b); +} + +.vc-card-meta-row { + display: flex; + align-items: center; + gap: 6px; +} + +.vc-card-meta-icon { + font-size: 13px; + flex-shrink: 0; +} + +/* 操作区 */ +.vc-card-footer { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + background: var(--bg-secondary, #f8fafc); + border-radius: 10px; +} + +.vc-card-footer .xx-btn { + flex: 1; +} + +/* ── 空状态 ─────────────────────────────────────────────── */ + +.vc-empty { + margin-top: 40px; + text-align: center; + padding: 60px 24px; + background: var(--bg-card, #fff); + border: 1px dashed var(--line, #e2e8f0); + border-radius: var(--radius-lg, 14px); +} + +.vc-empty-icon { + font-size: 64px; + margin-bottom: 16px; + line-height: 1; +} + +.vc-empty-title { + margin: 0 0 8px; + font-size: 18px; + font-weight: 600; + color: var(--text-primary, #1e293b); +} + +.vc-empty-desc { + margin: 0 0 24px; + font-size: 14px; + color: var(--muted, #64748b); +} + +/* ── Toast 提示 ─────────────────────────────────────────── */ + +.vc-toast-container { + position: fixed; + top: 80px; + right: 20px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; +} + +.vc-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(--line, #e2e8f0); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + animation: vc-toast-in 0.25s ease-out; + display: flex; + align-items: center; + gap: 8px; +} + +.vc-toast--success { + border-color: var(--success, #22c55e); + color: var(--success, #22c55e); +} + +.vc-toast--error { + border-color: var(--error, #ef4444); + color: var(--error, #ef4444); +} + +@keyframes vc-toast-in { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* ── 编辑弹窗 ───────────────────────────────────────────── */ + +.vc-edit-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + background: rgba(0, 0, 0, 0.4); + animation: vc-fade-in 0.2s; +} + +@keyframes vc-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.vc-edit-dialog { + background: var(--bg-card, #fff); + border-radius: var(--radius-lg, 14px); + padding: 24px; + width: 400px; + max-width: calc(100vw - 40px); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); + animation: vc-scale-in 0.2s ease-out; +} + +@keyframes vc-scale-in { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.vc-edit-title { + margin: 0 0 16px; + font-size: 16px; + font-weight: 600; + color: var(--text-primary, #1e293b); +} + +.vc-edit-input { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--line, #e2e8f0); + border-radius: var(--radius-sm, 8px); + font-size: 14px; + color: var(--text-primary, #1e293b); + background: var(--bg-primary, #fff); + outline: none; + transition: border-color 0.15s; + margin-bottom: 16px; +} + +.vc-edit-input:focus { + border-color: var(--primary, #4f46e5); +} + +.vc-edit-buttons { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +/* ── 响应式 ─────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .vc-grid { + grid-template-columns: 1fr; + } + + .vc-card-actions { + opacity: 1; + } + + .vc-empty { + padding: 40px 16px; + } + + .vc-empty-icon { + font-size: 48px; + } +} diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx index 5a441ab0d..9337909bf 100644 --- a/apps/web/src/router/index.tsx +++ b/apps/web/src/router/index.tsx @@ -124,6 +124,13 @@ export const router = createBrowserRouter([ Component: m.default, })), }, + { + path: "voice-clone", + lazy: () => + import("@/pages/voice-clone/VoiceClone").then((m) => ({ + Component: m.default, + })), + }, { path: "duplication", lazy: () =>