feat(ui): 新建我的音色页面
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 194h34m44s
CI/CD Pipeline / Frontend Lint (push) Failing after 194h35m24s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 194h35m35s

- 新增 api/voiceClone.ts: mock 数据模块(4条音色,含 ready/processing 状态)
- 新增 pages/voice-clone/VoiceClone.tsx: 主页面,卡片网格+空状态+编辑弹窗+toast
- 新增 pages/voice-clone/voice-clone.css: V21 设计系统样式
- 更新路由 /voice-clone、侧边栏菜单、Header导航、PageHead面包屑
- 零 antd 依赖,使用 V21 UI 组件(Button, Tooltip)
- TypeScript 检查通过
This commit is contained in:
灵应
2026-07-01 10:18:54 +08:00
parent 32bf71a9c3
commit 3a6d8d4bfd
7 changed files with 834 additions and 0 deletions
+127
View File
@@ -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<VoiceClone[]> => {
await delay(300);
return [...MOCK_CLONES];
};
/** 获取单个克隆音色详情 */
export const getVoiceCloneDetail = async (
id: string,
): Promise<VoiceClone | null> => {
await delay(200);
return MOCK_CLONES.find((v) => v.id === id) ?? null;
};
/** 创建克隆音色 */
export const createVoiceClone = async (
data: CreateVoiceCloneRequest,
): Promise<VoiceClone> => {
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<void> => {
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<Pick<VoiceClone, "name">>,
): Promise<VoiceClone> => {
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 };
};
@@ -51,6 +51,12 @@ const NAV_ITEMS: NavItem[] = [
icon: <FileTextOutlined />,
},
{ key: "voices", label: "配音库", path: "/voices", icon: <AudioOutlined /> },
{
key: "voice-clone",
label: "我的音色",
path: "/voice-clone",
icon: <AudioOutlined />,
},
{
key: "templates",
label: "模板库",
@@ -61,6 +61,7 @@ const ROUTE_TITLE_MAP: Record<string, string> = {
"/profile": "个人设置",
"/editing-planner": "剪辑规划",
"/my-templates": "我的模板",
"/voice-clone": "我的音色",
"/duplication": "查重",
"/duplication/results": "查重结果",
};
@@ -74,6 +74,12 @@ const MENU_GROUPS: SidebarMenuGroup[] = [
path: "/voices",
icon: <AudioOutlined />,
},
{
key: "voice-clone",
label: "我的音色",
path: "/voice-clone",
icon: <AudioOutlined />,
},
{
key: "titles",
label: "标题库",
@@ -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<VoiceCloneCardProps> = ({
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 (
<div className="vc-card">
{/* 右上角操作 */}
<div className="vc-card-actions">
<Tooltip title="编辑名称">
<button
type="button"
className="vc-card-action-btn"
onClick={() => onEdit(voice)}
>
</button>
</Tooltip>
<Tooltip title="删除">
<button
type="button"
className="vc-card-action-btn vc-card-action-btn--danger"
onClick={() => onDelete(voice)}
>
🗑
</button>
</Tooltip>
</div>
{/* 头部:头像 + 名称 + 状态 */}
<div className="vc-card-header">
<div
className={`vc-card-avatar${isProcessing ? " vc-card-avatar--processing" : ""}`}
>
🎤
</div>
<div className="vc-card-info">
<h4 className="vc-card-name">{voice.name}</h4>
<span className={`vc-status-pill ${statusCfg.className}`}>
<span className="vc-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
{/* 元信息 */}
<div className="vc-card-meta">
<div className="vc-card-meta-row">
<span className="vc-card-meta-icon">🎵</span>
<span>{formatDuration(voice.duration_seconds)}</span>
</div>
<div className="vc-card-meta-row">
<span className="vc-card-meta-icon">📅</span>
<span>{createdDate}</span>
</div>
</div>
{/* 操作区 */}
<div className="vc-card-footer">
<Button
buttonType="ghost"
buttonSize="sm"
disabled={isProcessing}
onClick={() => onPlay(voice)}
>
</Button>
<Button
buttonType="primary"
buttonSize="sm"
disabled={isProcessing}
onClick={() => onUse(voice)}
>
使
</Button>
</div>
</div>
);
};
/* ── 主页面 ─────────────────────────────────────────────── */
const VoiceClone: React.FC = () => {
const queryClient = useQueryClient();
const [toasts, setToasts] = useState<Toast[]>([]);
const [editingVoice, setEditingVoice] = useState<VoiceCloneType | null>(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 (
<div className="vc-page">
<PageHead
title="🎤 我的音色库"
description="克隆和管理你的专属音色,用AI生成个性化配音"
actions={
<Button buttonType="primary" onClick={handleCloneNew}>
</Button>
}
/>
{/* 加载状态 */}
{isLoading && (
<div className="vc-empty">
<div className="vc-empty-icon"></div>
<p className="vc-empty-desc">...</p>
</div>
)}
{/* 卡片网格 */}
{!isLoading && voices.length > 0 && (
<div className="vc-grid">
{voices.map((voice) => (
<VoiceCloneCard
key={voice.id}
voice={voice}
onPlay={handlePlay}
onUse={handleUse}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* 空状态 */}
{!isLoading && voices.length === 0 && (
<div className="vc-empty">
<div className="vc-empty-icon">🎤</div>
<h3 className="vc-empty-title"></h3>
<p className="vc-empty-desc">
AI将克隆你的专属音色
</p>
<Button buttonType="primary" onClick={handleCloneNew}>
</Button>
</div>
)}
{/* Toast 提示 */}
{toasts.length > 0 && (
<div className="vc-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
{t.type === "success" ? "✅" : "❌"} {t.message}
</div>
))}
</div>
)}
{/* 编辑弹窗 */}
{editingVoice && (
<div className="vc-edit-overlay" onClick={() => setEditingVoice(null)}>
<div className="vc-edit-dialog" onClick={(e) => e.stopPropagation()}>
<h3 className="vc-edit-title"></h3>
<input
type="text"
className="vc-edit-input"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleEditConfirm();
if (e.key === "Escape") setEditingVoice(null);
}}
autoFocus
placeholder="输入音色名称"
/>
<div className="vc-edit-buttons">
<Button buttonType="ghost" onClick={() => setEditingVoice(null)}>
</Button>
<Button
buttonType="primary"
onClick={handleEditConfirm}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? "保存中..." : "保存"}
</Button>
</div>
</div>
</div>
)}
</div>
);
};
export default VoiceClone;
@@ -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;
}
}
+7
View File
@@ -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: () =>