Merge pull request 'feat: 任务 3.15 克隆进度展示 — 轮询 Hook + 进度条 + 状态标签' (#153) from feat/clone-progress-3.15 into develop
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 162h27m23s
CI/CD Pipeline / Frontend Lint (push) Failing after 162h27m23s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 162h27m30s

This commit was merged in pull request #153.
This commit is contained in:
2026-07-02 18:27:04 +08:00
7 changed files with 479 additions and 208 deletions
+4
View File
@@ -1,6 +1,7 @@
/**
* 音色克隆 API
* 任务 3.11:替换 Mock 数据,对接后端真实 API(3.05)
* 任务 3.15:新增 progress 字段用于进度展示
*/
import apiClient from "./client";
@@ -16,6 +17,8 @@ export interface VoiceClone {
description: string;
duration_seconds: number;
status: VoiceCloneStatus;
/** 克隆进度 0-100,仅 processing 状态时有值 */
progress: number;
sample_url?: string;
language: string;
gender: string;
@@ -92,6 +95,7 @@ export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
description: profile.description || "",
duration_seconds: 0,
status: profile.status === "pending" ? "processing" : profile.status,
progress: 0,
sample_url: profile.source_audio_url || undefined,
language: profile.language || "",
gender: profile.gender || "",
+81
View File
@@ -0,0 +1,81 @@
/**
* 统一导航配置
* Header.tsx 和 Sidebar.tsx 共享此数据源,避免路由配置重复
*/
import React from "react";
import {
DashboardOutlined,
FileOutlined,
FileTextOutlined,
AudioOutlined,
AppstoreOutlined,
EditOutlined,
FolderOutlined,
VideoCameraOutlined,
HistoryOutlined,
TrophyOutlined,
ScanOutlined,
ControlOutlined,
CrownOutlined,
} from "@ant-design/icons";
/** 导航项类型 */
export interface NavItem {
key: string;
label: string;
path: string;
icon: React.ReactNode;
}
/** 导航分组类型 */
export interface NavGroup {
title: string;
items: NavItem[];
}
/** 全量导航项(Header 扁平列表使用) */
export const NAV_ITEMS: NavItem[] = [
{ key: "dashboard", label: "概览", path: "/dashboard", icon: React.createElement(DashboardOutlined) },
{ key: "assets", label: "素材库", path: "/assets", icon: React.createElement(FileOutlined) },
{ key: "titles", label: "标题库", path: "/titles", icon: React.createElement(FileTextOutlined) },
{ key: "voices", label: "配音库", path: "/voices", icon: React.createElement(AudioOutlined) },
{ key: "templates", label: "模板库", path: "/templates", icon: React.createElement(AppstoreOutlined) },
{ key: "editing-planner", label: "剪辑编辑器", path: "/editing-planner", icon: React.createElement(EditOutlined) },
{ key: "my-templates", label: "我的模板", path: "/my-templates", icon: React.createElement(FolderOutlined) },
{ key: "generate", label: "一键生成", path: "/generate", icon: React.createElement(VideoCameraOutlined) },
{ key: "history", label: "任务历史", path: "/history", icon: React.createElement(HistoryOutlined) },
{ key: "products", label: "成品库", path: "/products", icon: React.createElement(TrophyOutlined) },
{ key: "duplication", label: "查重", path: "/duplication", icon: React.createElement(ScanOutlined) },
];
/** 侧边栏导航分组(Sidebar 分组列表使用) */
export const NAV_GROUPS: NavGroup[] = [
{
title: "创作工具",
items: [
{ key: "dashboard", label: "概览", path: "/dashboard", icon: React.createElement(DashboardOutlined) },
{ key: "generate", label: "一键生成", path: "/generate", icon: React.createElement(VideoCameraOutlined) },
{ key: "editing-planner", label: "剪辑编辑器", path: "/editing-planner", icon: React.createElement(EditOutlined) },
],
},
{
title: "资源管理",
items: [
{ key: "assets", label: "素材库", path: "/assets", icon: React.createElement(FileOutlined) },
{ key: "voices", label: "配音库", path: "/voices", icon: React.createElement(AudioOutlined) },
{ key: "titles", label: "标题库", path: "/titles", icon: React.createElement(FileTextOutlined) },
{ key: "products", label: "成品库", path: "/products", icon: React.createElement(TrophyOutlined) },
{ key: "templates", label: "模板库", path: "/templates", icon: React.createElement(AppstoreOutlined) },
{ key: "my-templates", label: "我的模板", path: "/my-templates", icon: React.createElement(FolderOutlined) },
],
},
{
title: "系统",
items: [
{ key: "history", label: "任务历史", path: "/history", icon: React.createElement(HistoryOutlined) },
{ key: "duplication", label: "查重", path: "/duplication", icon: React.createElement(ScanOutlined) },
{ key: "admin", label: "控制台", path: "/admin", icon: React.createElement(ControlOutlined) },
{ key: "subscription", label: "订阅管理", path: "/subscription", icon: React.createElement(CrownOutlined) },
],
},
];
+90
View File
@@ -0,0 +1,90 @@
/**
* useCloneProgress — 克隆进度轮询 Hook
*
* 当存在 processing 状态的克隆条目时,每 3 秒自动拉取最新列表;
* 全部完成(ready / failed)后停止轮询。
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { getVoiceClones } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
const POLL_INTERVAL = 3000; // 3 秒
export const useCloneProgress = () => {
const [clones, setClones] = useState<VoiceClone[]>([]);
const [loading, setLoading] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
const fetchClones = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
try {
const data = await getVoiceClones();
setClones(data);
} catch {
// 静默失败,下次轮询重试
} finally {
if (!silent) setLoading(false);
}
}, []);
/* 初始加载 */
useEffect(() => {
fetchClones();
}, [fetchClones]);
/* 轮询:有 processing 条目时启动,全部结束时停止 */
useEffect(() => {
const hasProcessing = clones.some((c) => c.status === "processing");
if (hasProcessing) {
if (!timerRef.current) {
timerRef.current = setInterval(() => {
fetchClones(true);
}, POLL_INTERVAL);
}
} else {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = undefined;
}
}
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = undefined;
}
};
}, [clones, fetchClones]);
/** 手动刷新 */
const refresh = useCallback(() => fetchClones(), [fetchClones]);
/** 克隆成功后追加到列表 */
const addClone = useCallback((voice: VoiceClone) => {
setClones((prev) => [voice, ...prev]);
}, []);
/** 删除后从列表移除 */
const removeClone = useCallback((id: string) => {
setClones((prev) => prev.filter((c) => c.id !== id));
}, []);
/** 更新某条克隆(如改名) */
const updateClone = useCallback((updated: VoiceClone) => {
setClones((prev) =>
prev.map((c) => (c.id === updated.id ? updated : c)),
);
}, []);
return {
clones,
loading,
refresh,
addClone,
removeClone,
updateClone,
/** 是否有正在处理中的克隆 */
hasProcessing: clones.some((c) => c.status === "processing"),
};
};
+44 -54
View File
@@ -45,18 +45,22 @@ import {
import apiClient from "@/api/client";
import { fetchPresetVoices } from "@/api/voices";
import type { PresetVoiceItem } from "@/api/voices";
import { getVoiceClones, formatDuration } from "@/api/voiceClone";
import { formatDuration } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
import VoiceCloneModal from "@/components/modals/VoiceCloneModal";
import { synthesizeSpeech, getTTSJobStatus } from "@/api/tts";
import { useCloneProgress } from "@/hooks/useCloneProgress";
import "./generate.css";
const { TextArea } = Input;
const { Text } = Typography;
/* ================================================================
Mock 数据(时间线保留,素材 & 配音改为 API 获取)
================================================================ */
/* ── 克隆声音状态配置 ── */
const CLONE_STATUS_CONFIG: Record<string, { label: string; color: string }> = {
ready: { label: "就绪", color: "var(--secondary-color, #10b981)" },
processing: { label: "克隆中", color: "var(--accent-color, #f59e0b)" },
failed: { label: "失败", color: "var(--error-color, #ef4444)" },
};
/* ── 时间线 Mock ── */
interface TimelineScene {
@@ -178,10 +182,9 @@ const GeneratePage: React.FC = () => {
const [customVoiceText, setCustomVoiceText] = useState("");
/* ── 克隆声音 ── */
const [clonedVoices, setClonedVoices] = useState<VoiceClone[]>([]);
const [selectedClonedVoice, setSelectedClonedVoice] = useState<string>("");
const [cloneModalOpen, setCloneModalOpen] = useState(false);
const [loadingClones, setLoadingClones] = useState(false);
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress();
/* ── 标题/文案 ── */
const [titleMode, setTitleMode] = useState<"ai" | "manual">("ai");
@@ -228,30 +231,13 @@ const GeneratePage: React.FC = () => {
enabled: libraryId !== undefined,
});
/* ── 获取克隆音色列表 ── */
const fetchClonedVoices = useCallback(async () => {
setLoadingClones(true);
try {
const data = await getVoiceClones();
setClonedVoices(data);
} catch {
message.error("获取克隆音色失败");
} finally {
setLoadingClones(false);
}
}, []);
useEffect(() => {
fetchClonedVoices();
}, [fetchClonedVoices]);
const handleCloneSuccess = useCallback(
(voice: VoiceClone) => {
setClonedVoices((prev) => [voice, ...prev]);
addClone(voice);
setCloneModalOpen(false);
message.success("音色克隆成功!");
},
[],
[addClone],
);
/* ── 事件 ── */
@@ -794,27 +780,29 @@ const GeneratePage: React.FC = () => {
</Button>
</div>
{/* 轮询提示 */}
{hasProcessing && (
<div className="xx-clone-polling-hint">
<span className="xx-clone-polling-dot" />
...
</div>
)}
{/* 已克隆声音列表 */}
{loadingClones ? (
<Typography.Paragraph
style={{ color: "var(--text-secondary)" }}
>
</Typography.Paragraph>
) : clonedVoices.length > 0 ? (
{clonedVoices.length > 0 ? (
<div className="xx-clone-voices-list">
<Typography.Paragraph className="xx-clone-voices-label">
{clonedVoices.length}
</Typography.Paragraph>
<div className="xx-voice-grid">
{clonedVoices.map((cv) => {
const selected = selectedClonedVoice === cv.id;
const statusCfg = CLONE_STATUS_CONFIG[cv.status];
const isReady = cv.status === "ready";
const isProcessing = cv.status === "processing";
const selected = selectedClonedVoice === cv.id;
return (
<div
key={cv.id}
className={`xx-voice-card ${selected ? "xx-voice-card-selected" : ""} ${!isReady ? "xx-voice-card-disabled" : ""}`}
className={`xx-voice-card xx-voice-card--${cv.status} ${selected ? "xx-voice-card-selected" : ""} ${!isReady ? "xx-voice-card-disabled" : ""}`}
onClick={() => {
if (isReady) setSelectedClonedVoice(cv.id);
}}
@@ -830,32 +818,26 @@ const GeneratePage: React.FC = () => {
}}
>
<div className="xx-voice-header">
<div className="xx-voice-avatar">
<div className={`xx-voice-avatar xx-voice-avatar--${cv.status}`}>
<AudioOutlined />
</div>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Typography.Paragraph className="xx-voice-name">
{cv.name}
</Typography.Paragraph>
<Typography.Paragraph className="xx-voice-desc">
<div className="xx-voice-status-row">
<span
className="xx-voice-status-dot"
style={{ background: statusCfg.color }}
/>
<span style={{ color: statusCfg.color, fontSize: 12 }}>
{statusCfg.label}
</span>
{isReady && (
<span style={{ color: "var(--text-secondary)", fontSize: 12, marginLeft: 8 }}>
{formatDuration(cv.duration_seconds)}
{isProcessing && (
<Tag
variant="warning"
style={{ marginLeft: 6 }}
>
</Tag>
</span>
)}
{cv.status === "failed" && (
<Tag
variant="error"
style={{ marginLeft: 6 }}
>
</Tag>
)}
</Typography.Paragraph>
</div>
</div>
{selected && isReady && (
@@ -864,6 +846,14 @@ const GeneratePage: React.FC = () => {
</div>
)}
</div>
{/* 进度条(克隆中 — indeterminate 条纹流动动画) */}
{cv.status === "processing" && (
<div className="xx-clone-progress xx-clone-progress--indeterminate">
<div className="xx-clone-progress-bar" />
<span className="xx-clone-progress-text"></span>
</div>
)}
</div>
);
})}
</div>
+134
View File
@@ -632,6 +632,140 @@
margin: 0 0 var(--space-sm) !important;
}
/* ── 克隆进度展示(任务 3.15) ─────────────────────── */
/* 轮询提示 */
.xx-clone-polling-hint {
display: flex;
align-items: center;
gap: 8px;
margin-top: var(--space-sm);
padding: 6px 12px;
font-size: var(--font-size-xs);
color: var(--accent-color, #f59e0b);
background: color-mix(in srgb, var(--accent-color, #f59e0b) 8%, transparent);
border-radius: var(--radius-sm, 8px);
animation: xx-clone-polling-fade 2s ease-in-out infinite;
}
.xx-clone-polling-dot {
width: 6px;
height: 6px;
border-radius: var(--radius-full);
background: var(--accent-color, #f59e0b);
animation: xx-clone-blink 1.5s ease-in-out infinite;
}
@keyframes xx-clone-polling-fade {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
@keyframes xx-clone-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* 卡片状态变体 */
.xx-voice-card--processing {
border-color: color-mix(in srgb, var(--accent-color, #f59e0b) 40%, transparent);
background: color-mix(in srgb, var(--accent-color, #f59e0b) 4%, var(--bg-primary));
}
.xx-voice-card--failed {
border-color: color-mix(in srgb, var(--error-color, #ef4444) 30%, transparent);
opacity: 0.75;
}
/* 头像状态变体 */
.xx-voice-avatar--processing {
background: linear-gradient(135deg, var(--accent-color, #f59e0b), var(--accent-dark, #d97706));
animation: xx-clone-pulse 2s ease-in-out infinite;
}
.xx-voice-avatar--failed {
background: linear-gradient(135deg, var(--color-gray-400, #94a3b8), var(--color-gray-500, #64748b));
}
@keyframes xx-clone-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* 状态行 */
.xx-voice-status-row {
display: flex;
align-items: center;
gap: 5px;
margin-top: 4px;
}
.xx-voice-status-dot {
width: 6px;
height: 6px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
/* 进度条 */
.xx-clone-progress {
position: relative;
height: 5px;
background: var(--bg-tertiary, #f1f5f9);
border-radius: 3px;
margin-top: var(--space-sm);
overflow: hidden;
}
.xx-clone-progress-bar {
height: 100%;
background: linear-gradient(
90deg,
var(--accent-color, #f59e0b),
var(--secondary-color, #10b981)
);
border-radius: 3px;
transition: width 0.5s ease;
}
/* Indeterminate 态:条纹流动动画(progress=0 时后端无进度数据) */
.xx-clone-progress--indeterminate .xx-clone-progress-bar {
width: 100%;
background: repeating-linear-gradient(
90deg,
var(--accent-color, #f59e0b) 0%,
var(--accent-color, #f59e0b) 25%,
var(--secondary-color, #10b981) 25%,
var(--secondary-color, #10b981) 50%,
var(--accent-color, #f59e0b) 50%
);
background-size: 60px 100%;
animation: xx-clone-progress-flow 1.2s linear infinite;
}
@keyframes xx-clone-progress-flow {
from { background-position: 0 0; }
to { background-position: 60px 0; }
}
.xx-clone-progress--indeterminate .xx-clone-progress-text {
animation: xx-clone-progress-pulse 1.5s ease-in-out infinite;
}
@keyframes xx-clone-progress-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.xx-clone-progress-text {
position: absolute;
right: 0;
top: -16px;
font-size: 11px;
color: var(--accent-color, #f59e0b);
font-weight: 600;
}
/* ============================================================
结果区域
============================================================ */
+56 -139
View File
@@ -1,10 +1,10 @@
/**
* 我的音色页面 — V21 设计系统(任务 3.12 升级)
* 我的音色页面 — V21 设计系统(任务 3.12 升级 / 3.15 进度轮询
*
* 功能:克隆音色卡片列表、试听播放、状态标签、删除、编辑名称、空状态引导
* Mock 数据 + 预留 CosyVoice API 对接接口
* 使用 useCloneProgress hook 实现 processing 状态自动轮询
*/
import React, { useState, useCallback, useRef, useEffect } from "react";
import React, { useState, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
PlayCircleOutlined,
@@ -17,107 +17,18 @@ import {
} from "@ant-design/icons";
import { Button, Modal, Input, Tooltip } from "@/components/ui";
import PageHead from "@/components/layout/PageHead";
import { useCloneProgress } from "@/hooks/useCloneProgress";
import {
deleteVoiceClone,
updateVoiceClone,
formatDuration,
} from "@/api/voiceClone";
import type { VoiceClone, VoiceCloneStatus } from "@/api/voiceClone";
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" });
@@ -126,7 +37,7 @@ function formatDate(isoStr: string): string {
/* ============================================================
* 状态配置
* ============================================================ */
const STATUS_CONFIG: Record<CloneStatus, { label: string; dotClass: string }> = {
const STATUS_CONFIG: Record<VoiceCloneStatus, { 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" },
@@ -146,11 +57,11 @@ let _toastId = 0;
* 音色卡片组件
* ============================================================ */
interface VoiceCardProps {
voice: ClonedVoice;
voice: VoiceClone;
isPlaying: boolean;
onTogglePlay: (voice: ClonedVoice) => void;
onEdit: (voice: ClonedVoice) => void;
onDelete: (voice: ClonedVoice) => void;
onTogglePlay: (voice: VoiceClone) => void;
onEdit: (voice: VoiceClone) => void;
onDelete: (voice: VoiceClone) => void;
}
const VoiceCard: React.FC<VoiceCardProps> = ({
@@ -182,20 +93,20 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
{/* 元信息 */}
<div className="xx-mv-card-meta">
<span className="xx-mv-card-meta-item">
<ClockCircleOutlined /> {formatDate(voice.createdAt)}
<ClockCircleOutlined /> {formatDate(voice.created_at)}
</span>
{voice.durationSeconds > 0 && (
{voice.duration_seconds > 0 && (
<span className="xx-mv-card-meta-item">
{formatDuration(voice.durationSeconds)}
{formatDuration(voice.duration_seconds)}
</span>
)}
</div>
{/* 进度条(克隆中) */}
{/* 进度条(克隆中 — indeterminate 条纹流动动画 */}
{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 className="xx-mv-progress xx-mv-progress--indeterminate">
<div className="xx-mv-progress-bar" />
<span className="xx-mv-progress-text"></span>
</div>
)}
@@ -249,24 +160,15 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
* ============================================================ */
const MyVoices: React.FC = () => {
const navigate = useNavigate();
const [voices, setVoices] = useState<ClonedVoice[]>([]);
const [loading, setLoading] = useState(true);
const { clones, loading, removeClone, updateClone, hasProcessing } = useCloneProgress();
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 [editingVoice, setEditingVoice] = useState<VoiceClone | 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;
@@ -275,7 +177,7 @@ const MyVoices: React.FC = () => {
}, []);
// 试听播放
const handleTogglePlay = useCallback(async (voice: ClonedVoice) => {
const handleTogglePlay = useCallback((voice: VoiceClone) => {
if (playingId === voice.id) {
audioRef.current?.pause();
setPlayingId(null);
@@ -284,7 +186,8 @@ const MyVoices: React.FC = () => {
if (audioRef.current) {
audioRef.current.pause();
}
const url = await getVoicePreviewUrl(voice.id);
// Mock: 使用 sample_url 或占位 URL
const url = voice.sample_url || `/mock/audio/clone-${voice.id}.mp3`;
const audio = new Audio(url);
audioRef.current = audio;
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"));
@@ -293,7 +196,7 @@ const MyVoices: React.FC = () => {
}, [playingId, showToast]);
// 编辑
const handleEdit = (voice: ClonedVoice) => {
const handleEdit = (voice: VoiceClone) => {
setEditingVoice(voice);
setEditName(voice.name);
setEditModalOpen(true);
@@ -301,26 +204,32 @@ const MyVoices: React.FC = () => {
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))
);
try {
const updated = await updateVoiceClone(editingVoice.id, { name: editName.trim() });
updateClone(updated);
setEditModalOpen(false);
setEditingVoice(null);
showToast("名称已更新", "success");
} catch {
showToast("更新失败,请重试", "error");
}
};
// 删除
const handleDelete = (voice: ClonedVoice) => {
const handleDelete = (voice: VoiceClone) => {
setDeleteConfirmId(voice.id);
};
const handleDeleteConfirm = async () => {
if (!deleteConfirmId) return;
await deleteClonedVoice(deleteConfirmId);
setVoices((prev) => prev.filter((v) => v.id !== deleteConfirmId));
try {
await deleteVoiceClone(deleteConfirmId);
removeClone(deleteConfirmId);
setDeleteConfirmId(null);
showToast("音色已删除", "success");
} catch {
showToast("删除失败,请重试", "error");
}
};
// 克隆新音色
@@ -329,8 +238,8 @@ const MyVoices: React.FC = () => {
};
// 统计
const readyCount = voices.filter((v) => v.status === "ready").length;
const processingCount = voices.filter((v) => v.status === "processing").length;
const readyCount = clones.filter((v) => v.status === "ready").length;
const processingCount = clones.filter((v) => v.status === "processing").length;
return (
<div className="xx-mv-page">
@@ -348,11 +257,19 @@ const MyVoices: React.FC = () => {
}
/>
{/* 轮询提示 */}
{hasProcessing && (
<div className="xx-mv-polling-hint">
<span className="xx-mv-polling-dot" />
...
</div>
)}
{/* 统计栏 */}
{!loading && voices.length > 0 && (
{!loading && clones.length > 0 && (
<div className="xx-mv-stats">
<span className="xx-mv-stat">
<strong>{voices.length}</strong>
<strong>{clones.length}</strong>
</span>
<span className="xx-mv-stat xx-mv-stat--ready">
<span className="xx-mv-stat-dot xx-mv-stat-dot--ready" />
@@ -376,9 +293,9 @@ const MyVoices: React.FC = () => {
)}
{/* 卡片网格 */}
{!loading && voices.length > 0 && (
{!loading && clones.length > 0 && (
<div className="xx-mv-grid">
{voices.map((voice) => (
{clones.map((voice) => (
<VoiceCard
key={voice.id}
voice={voice}
@@ -392,7 +309,7 @@ const MyVoices: React.FC = () => {
)}
{/* 空状态 */}
{!loading && voices.length === 0 && (
{!loading && clones.length === 0 && (
<div className="xx-mv-empty">
<div className="xx-mv-empty-icon">🎤</div>
<h3 className="xx-mv-empty-title"></h3>
+60 -5
View File
@@ -11,6 +11,33 @@
gap: var(--space-md, 20px);
}
/* ── 轮询提示 ──────────────────────────────────────────── */
.xx-mv-polling-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 13px;
color: var(--accent-color, #f59e0b);
background: color-mix(in srgb, var(--accent-color, #f59e0b) 8%, transparent);
border-radius: var(--radius-md, 10px);
animation: xx-mv-polling-fade 2s ease-in-out infinite;
}
.xx-mv-polling-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-color, #f59e0b);
animation: xx-mv-blink 1.5s ease-in-out infinite;
}
@keyframes xx-mv-polling-fade {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* ── 统计栏 ────────────────────────────────────────────── */
.xx-mv-stats {
@@ -211,15 +238,43 @@
.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;
}
/* Indeterminate 态:条纹流动动画(后端无 progress 字段) */
.xx-mv-progress--indeterminate {
background: var(--bg-tertiary, #f1f5f9);
}
.xx-mv-progress--indeterminate .xx-mv-progress-bar {
width: 100%;
background: repeating-linear-gradient(
90deg,
var(--accent-color, #f59e0b) 0%,
var(--accent-color, #f59e0b) 25%,
var(--secondary-color, #10b981) 25%,
var(--secondary-color, #10b981) 50%,
var(--accent-color, #f59e0b) 50%
);
background-size: 60px 100%;
animation: xx-mv-progress-flow 1.2s linear infinite;
}
@keyframes xx-mv-progress-flow {
from { background-position: 0 0; }
to { background-position: 60px 0; }
}
.xx-mv-progress--indeterminate .xx-mv-progress-text {
animation: xx-mv-progress-pulse 1.5s ease-in-out infinite;
}
@keyframes xx-mv-progress-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.xx-mv-progress-text {
position: absolute;
right: 0;