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
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:
@@ -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 || "",
|
||||
|
||||
@@ -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) },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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"),
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
结果区域
|
||||
============================================================ */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user