feat(task-3.12): 我的音色页面 - 克隆音色卡片 + 状态管理 + 空状态引导 #146
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user