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
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: () =>
|
||||
|
||||
Reference in New Issue
Block a user