feat(task-3.12): 我的音色页面 - 克隆音色卡片 + 状态管理 + 空状态引导 #146

Closed
xiaoxia wants to merge 1 commits from feat/my-voices-page into develop
3 changed files with 860 additions and 0 deletions
+455
View File
@@ -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<ClonedVoice[]> {
// 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<void> {
// TODO: 替换为真实 API
// await fetch(`/api/v1/voice-clones/${id}`, { method: "DELETE" });
console.log("Delete voice:", id);
}
async function updateClonedVoiceName(id: string, name: string): Promise<void> {
// 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<string> {
// 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<CloneStatus, { label: string; dotClass: string }> = {
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<VoiceCardProps> = ({
voice,
isPlaying,
onTogglePlay,
onEdit,
onDelete,
}) => {
const statusCfg = STATUS_CONFIG[voice.status];
const isReady = voice.status === "ready";
return (
<div className={`xx-mv-card xx-mv-card--${voice.status}`}>
{/* 头部:头像 + 名称 + 状态 */}
<div className="xx-mv-card-header">
<div className={`xx-mv-card-avatar xx-mv-card-avatar--${voice.status}`}>
<SoundOutlined />
</div>
<div className="xx-mv-card-info">
<h4 className="xx-mv-card-name">{voice.name}</h4>
<span className="xx-mv-status">
<span className={`xx-mv-status-dot ${statusCfg.dotClass}`} />
{statusCfg.label}
</span>
</div>
</div>
{/* 元信息 */}
<div className="xx-mv-card-meta">
<span className="xx-mv-card-meta-item">
<ClockCircleOutlined /> {formatDate(voice.createdAt)}
</span>
{voice.durationSeconds > 0 && (
<span className="xx-mv-card-meta-item">
{formatDuration(voice.durationSeconds)}
</span>
)}
</div>
{/* 进度条(克隆中) */}
{voice.status === "processing" && (
<div className="xx-mv-progress">
<div className="xx-mv-progress-bar" style={{ width: `${voice.progress}%` }} />
<span className="xx-mv-progress-text">{voice.progress}%</span>
</div>
)}
{/* 操作区 */}
<div className="xx-mv-card-actions">
{isReady ? (
<Button
buttonType={isPlaying ? "secondary" : "ghost"}
buttonSize="sm"
onClick={() => onTogglePlay(voice)}
>
{isPlaying ? <><PauseCircleOutlined /> </> : <><PlayCircleOutlined /> </>}
</Button>
) : voice.status === "failed" ? (
<Button buttonType="ghost" buttonSize="sm" disabled>
</Button>
) : (
<Button buttonType="ghost" buttonSize="sm" disabled>
...
</Button>
)}
<div className="xx-mv-card-icon-actions">
<Tooltip title="编辑名称">
<button
type="button"
className="xx-mv-icon-btn"
onClick={() => onEdit(voice)}
disabled={!isReady}
>
<EditOutlined />
</button>
</Tooltip>
<Tooltip title="删除">
<button
type="button"
className="xx-mv-icon-btn xx-mv-icon-btn--danger"
onClick={() => onDelete(voice)}
>
<DeleteOutlined />
</button>
</Tooltip>
</div>
</div>
</div>
);
};
/* ============================================================
* 主页面组件
* ============================================================ */
const MyVoices: React.FC = () => {
const navigate = useNavigate();
const [voices, setVoices] = useState<ClonedVoice[]>([]);
const [loading, setLoading] = useState(true);
const [playingId, setPlayingId] = useState<string | null>(null);
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingVoice, setEditingVoice] = useState<ClonedVoice | null>(null);
const [editName, setEditName] = useState("");
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(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 (
<div className="xx-mv-page">
<PageHead
title="我的音色"
description="管理你的克隆音色,试听并使用在视频配音中"
breadcrumb={[
{ label: "配音中心", path: "/app/voices" },
{ label: "我的音色" },
]}
actions={
<Button buttonType="primary" onClick={handleCloneNew}>
<PlusOutlined />
</Button>
}
/>
{/* 统计栏 */}
{!loading && voices.length > 0 && (
<div className="xx-mv-stats">
<span className="xx-mv-stat">
<strong>{voices.length}</strong>
</span>
<span className="xx-mv-stat xx-mv-stat--ready">
<span className="xx-mv-stat-dot xx-mv-stat-dot--ready" />
{readyCount}
</span>
{processingCount > 0 && (
<span className="xx-mv-stat xx-mv-stat--processing">
<span className="xx-mv-stat-dot xx-mv-stat-dot--processing" />
{processingCount}
</span>
)}
</div>
)}
{/* 加载状态 */}
{loading && (
<div className="xx-mv-empty">
<div className="xx-mv-empty-icon"></div>
<p className="xx-mv-empty-desc">...</p>
</div>
)}
{/* 卡片网格 */}
{!loading && voices.length > 0 && (
<div className="xx-mv-grid">
{voices.map((voice) => (
<VoiceCard
key={voice.id}
voice={voice}
isPlaying={playingId === voice.id}
onTogglePlay={handleTogglePlay}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* 空状态 */}
{!loading && voices.length === 0 && (
<div className="xx-mv-empty">
<div className="xx-mv-empty-icon">🎤</div>
<h3 className="xx-mv-empty-title"></h3>
<p className="xx-mv-empty-desc">
AI
</p>
<Button buttonType="primary" onClick={handleCloneNew}>
<PlusOutlined />
</Button>
</div>
)}
{/* 编辑弹窗 */}
<Modal
open={editModalOpen}
title="编辑音色名称"
onCancel={() => setEditModalOpen(false)}
onOk={handleEditConfirm}
okText="保存"
cancelText="取消"
>
<Input
value={editName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEditName(e.target.value)}
placeholder="输入音色名称"
autoFocus
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === "Enter") handleEditConfirm();
}}
/>
</Modal>
{/* 删除确认弹窗 */}
<Modal
open={!!deleteConfirmId}
title="确认删除"
onCancel={() => setDeleteConfirmId(null)}
onOk={handleDeleteConfirm}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true } as any}
>
<p></p>
</Modal>
{/* Toast */}
{toasts.length > 0 && (
<div className="xx-mv-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`xx-mv-toast xx-mv-toast--${t.type}`}>
{t.type === "success" ? "✅" : "❌"} {t.message}
</div>
))}
</div>
)}
</div>
);
};
export default MyVoices;
+398
View File
@@ -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;
}
}
+7
View File
@@ -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",