Files
xiaoxia-saas/apps/web/src/pages/voices/VoiceLibrary.tsx
T
灵应 3634167dba
CI/CD Pipeline / Staging E2E Tests (push) Failing after 26h54m44s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 27h0m5s
CI/CD Pipeline / Frontend Lint (push) Failing after 27h1m2s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 27h1m2s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1685h36m6s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1685h36m10s
CI/CD Pipeline / Deploy Production (push) Failing after 1686h7m36s
style: fix prettier formatting in 3 frontend files
- AssetLibrary.tsx
- GeneratePage.tsx
- VoiceLibrary.tsx

This fixes the CI Frontend Lint job failure.
2026-07-08 09:53:24 +08:00

1761 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 配音库页面 — V21 设计系统
*
* 任务 3.11:删除 Mock 数据,使用 useQuery 对接后端真实 API
*
* Tab 1:预置音色 — fetchPresetVoices()
* Tab 2:我的克隆 — getVoiceClonesWithTotal()
* 统计:fetchVoices({ limit: 1 }) 获取 preset_count / clone_count
*/
import React, {
useMemo,
useState,
useRef,
useEffect,
useCallback,
} from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
SoundOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
SearchOutlined,
PlusOutlined,
RobotOutlined,
UploadOutlined,
AudioOutlined,
HeartOutlined,
UserOutlined,
DeleteOutlined,
ReloadOutlined,
CloseCircleOutlined,
} from "@ant-design/icons";
import { Modal, Upload, message } from "antd";
import { Button, Input, Select, Tooltip } from "@/components/ui";
import PageHead from "@/components/layout/PageHead";
import {
fetchPresetVoices,
fetchVoices,
type PresetVoiceItem,
} from "@/api/voices";
import {
getVoiceClonesWithTotal,
deleteVoiceClone,
retryVoiceClone,
toVoiceClone,
type VoiceClone,
} from "@/api/voiceClone";
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts";
import {
uploadAssetDirect,
getAssetLibraries,
createAsset,
} from "@/api/assets";
import CloneModal from "@/components/voice/CloneModal";
import "./voices.css";
/* ============================================================
* 类型
* ============================================================ */
type VoiceGender = "male" | "female" | "child" | "elderly";
type VoiceLanguage = "zh" | "en" | "ja" | "ko";
type TabKey = "preset" | "cloned";
/** 前端展示用的预置音色(从 PresetVoiceItem 映射) */
interface PresetVoiceDisplay {
id: string;
name: string;
gender: VoiceGender;
language: VoiceLanguage;
duration: number;
tags: string[];
description: string;
voiceId: string;
previewUrl: string;
starred: boolean;
}
/** 前端展示用的克隆音色(从 VoiceClone 映射) */
interface ClonedVoiceDisplay {
id: string;
name: string;
description: string;
sourceName: string;
status: "ready" | "processing" | "failed";
createdAt: string;
duration: number;
tags: string[];
voiceId: string;
language: string;
gender: string;
errorMessage: string | null;
sampleUrl?: string;
}
/* ============================================================
* 映射函数
* ============================================================ */
const mapPresetToDisplay = (item: PresetVoiceItem): PresetVoiceDisplay => ({
id: item.voice_id,
name: item.name,
gender: (item.gender as VoiceGender) || "female",
language: (item.language as VoiceLanguage) || "zh",
duration: 0,
tags: item.tags,
description: item.description,
voiceId: item.voice_id,
previewUrl: item.preview_url || "",
starred: false,
});
const mapCloneToDisplay = (clone: VoiceClone): ClonedVoiceDisplay => ({
id: clone.id,
name: clone.name,
description: clone.description || "",
sourceName: clone.sample_url || "未知来源",
status: clone.status,
createdAt: new Date(clone.created_at).toLocaleDateString("zh-CN"),
duration: clone.duration_seconds,
tags: [],
voiceId: clone.id,
language: clone.language || "",
gender: clone.gender || "",
errorMessage: clone.error_message || null,
sampleUrl: clone.sample_url || undefined,
});
/* ============================================================
* 工具函数
* ============================================================ */
const genderLabel = (g: VoiceGender) => {
const map: Record<VoiceGender, string> = {
male: "男声",
female: "女声",
child: "童声",
elderly: "老年",
};
return map[g];
};
const languageLabel = (l: VoiceLanguage) => {
const map: Record<VoiceLanguage, string> = {
zh: "中文",
en: "英文",
ja: "日文",
ko: "韩文",
};
return map[l];
};
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
};
const genderClass = (g: VoiceGender) => `xx-voice-gender--${g}`;
/* ============================================================
* VoiceCard 组件(预置音色 + 克隆音色统一)
* ============================================================ */
interface VoiceCardProps {
id: string;
name: string;
subtitle: string;
tags: string[];
duration: number;
gender: VoiceGender;
isPlaying: boolean;
isSelected: boolean;
currentTime: number;
starred?: boolean;
status?: "ready" | "processing" | "failed";
onPlay: () => void;
onPause: () => void;
onSeek: (time: number) => void;
onSelect?: () => void;
onToggleStar?: () => void;
}
const VoiceCard: React.FC<VoiceCardProps> = ({
id: _id,
name,
subtitle,
tags,
duration,
gender,
isPlaying,
isSelected,
currentTime,
starred,
status = "ready",
onPlay,
onPause,
onSeek,
onSelect,
onToggleStar,
}) => {
const progressRef = useRef<HTMLDivElement>(null);
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current || status !== "ready") return;
const rect = progressRef.current.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
onSeek(percent * duration);
};
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div
className={`xx-voice-card ${genderClass(gender)}${isSelected ? " selected" : ""}${isPlaying ? " playing" : ""}`}
onClick={onSelect}
>
<div className="xx-voice-avatar">
<SoundOutlined />
</div>
<div className="xx-voice-info">
<div className="xx-voice-name-row">
<h4 className="xx-voice-name" title={name}>
{name}
</h4>
{starred !== undefined && (
<button
className={`xx-voice-star${starred ? " active" : ""}`}
onClick={(e) => {
e.stopPropagation();
onToggleStar?.();
}}
title={starred ? "取消收藏" : "收藏"}
>
<HeartOutlined />
</button>
)}
</div>
<div className="xx-voice-subtitle">{subtitle}</div>
<div className="xx-voice-tags">
{tags.slice(0, 3).map((tag) => (
<span key={tag} className="xx-voice-tag">
{tag}
</span>
))}
</div>
</div>
{status === "processing" && (
<div className="xx-voice-status xx-voice-status--processing">
<span className="xx-voice-status-dot" />
处理中...
</div>
)}
{status === "failed" && (
<div className="xx-voice-status xx-voice-status--failed">克隆失败</div>
)}
{status === "ready" && <div className="xx-voice-wave" />}
{status === "ready" && (
<div className="xx-voice-controls">
<button
className="xx-voice-play-btn"
onClick={(e) => {
e.stopPropagation();
isPlaying ? onPause() : onPlay();
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div
ref={progressRef}
className="xx-voice-progress"
onClick={handleProgressClick}
>
<div
className="xx-voice-progress-bar"
style={{ width: `${progress}%` }}
/>
</div>
<span className="xx-voice-time">
{isPlaying ? formatTime(currentTime) : formatTime(duration)}
</span>
</div>
)}
</div>
);
};
/* ============================================================
* 克隆音色卡片组件(任务 3.12)
* ============================================================ */
/** 状态配置 */
const CLONE_STATUS_CONFIG: Record<
ClonedVoiceDisplay["status"],
{ label: string; className: string }
> = {
ready: { label: "可用", className: "xx-clone-status--ready" },
processing: { label: "处理中", className: "xx-clone-status--processing" },
failed: { label: "失败", className: "xx-clone-status--failed" },
};
/** Toast 类型 */
interface Toast {
id: number;
message: string;
type: "success" | "error";
}
let toastIdSeq = 0;
/** 克隆音色卡片 */
interface CloneVoiceCardProps {
voice: ClonedVoiceDisplay;
isPlaying: boolean;
currentTime: number;
onPlay: () => void;
onPause: () => void;
onUse: () => void;
onDelete: () => void;
onRetry: () => void;
onShowDetail: () => void;
}
const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
voice,
isPlaying,
currentTime,
onPlay,
onPause,
onUse,
onDelete,
onRetry,
onShowDetail,
}) => {
const statusCfg = CLONE_STATUS_CONFIG[voice.status];
const isFailed = voice.status === "failed";
const isProcessing = voice.status === "processing";
const genderText =
voice.gender === "male"
? "男声"
: voice.gender === "female"
? "女声"
: voice.gender;
return (
<div
className={`xx-clone-card${isPlaying ? " playing" : ""}${isFailed ? " failed" : ""}`}
onClick={isFailed ? undefined : onShowDetail}
>
{/* 右上角操作按钮 */}
<div className="xx-clone-card-actions">
<Tooltip title="删除">
<button
type="button"
className="xx-clone-action-btn xx-clone-action-btn--danger"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<DeleteOutlined />
</button>
</Tooltip>
{isFailed && (
<Tooltip title="重试">
<button
type="button"
className="xx-clone-action-btn"
onClick={(e) => {
e.stopPropagation();
onRetry();
}}
>
<ReloadOutlined />
</button>
</Tooltip>
)}
</div>
{/* 头部:头像 + 名称 + 状态 */}
<div className="xx-clone-card-header">
<div
className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}
>
<SoundOutlined />
</div>
<div className="xx-clone-header-info">
<h4 className="xx-clone-name" title={voice.name}>
{voice.name}
</h4>
<span className={`xx-clone-status ${statusCfg.className}`}>
<span className="xx-clone-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
{/* 描述 */}
{voice.description && (
<p className="xx-clone-desc">{voice.description}</p>
)}
{/* 元信息 */}
<div className="xx-clone-meta">
{(voice.gender || voice.language) && (
<span className="xx-clone-meta-item">
<UserOutlined />
{genderText}
{voice.language ? ` · ${voice.language}` : ""}
</span>
)}
<span className="xx-clone-meta-item">{voice.createdAt}</span>
</div>
{/* 错误信息 */}
{isFailed && voice.errorMessage && (
<div className="xx-clone-error">
<CloseCircleOutlined />
<span>{voice.errorMessage}</span>
</div>
)}
{/* 底部操作区 */}
<div className="xx-clone-footer">
{voice.status === "ready" && (
<>
<button
type="button"
className="xx-clone-play-btn"
onClick={(e) => {
e.stopPropagation();
isPlaying ? onPause() : onPlay();
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div className="xx-clone-progress">
<div
className="xx-clone-progress-bar"
style={{
width: isPlaying
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
: "0%",
}}
/>
</div>
<button
type="button"
className="xx-clone-use-btn"
onClick={(e) => {
e.stopPropagation();
onUse();
}}
>
使用
</button>
</>
)}
{isProcessing && (
<div className="xx-clone-processing-hint">
<ReloadOutlined spin />
克隆处理中,请稍候...
</div>
)}
{isFailed && (
<button
type="button"
className="xx-clone-retry-btn"
onClick={(e) => {
e.stopPropagation();
onRetry();
}}
>
<ReloadOutlined />
重试克隆
</button>
)}
</div>
</div>
);
};
/** 克隆音色详情弹窗 */
const CloneDetailModal: React.FC<{
voice: ClonedVoiceDisplay;
onClose: () => void;
onUse: () => void;
onDelete: () => void;
onRetry: () => void;
}> = ({ voice, onClose, onUse, onDelete, onRetry }) => {
const statusCfg = CLONE_STATUS_CONFIG[voice.status];
const genderText =
voice.gender === "male"
? "男声"
: voice.gender === "female"
? "女声"
: voice.gender || "未知";
const langText = voice.language || "未知";
return (
<div className="xx-clone-detail-overlay" onClick={onClose}>
<div className="xx-clone-detail" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="xx-clone-detail-close"
onClick={onClose}
>
<CloseCircleOutlined />
</button>
<div className="xx-clone-detail-header">
<div className="xx-clone-avatar">
<SoundOutlined />
</div>
<div>
<h3 className="xx-clone-detail-name">{voice.name}</h3>
<span className={`xx-clone-status ${statusCfg.className}`}>
<span className="xx-clone-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
{voice.description && (
<p className="xx-clone-detail-desc">{voice.description}</p>
)}
<div className="xx-clone-detail-info">
<div className="xx-clone-detail-row">
<span className="xx-clone-detail-label">性别</span>
<span>{genderText}</span>
</div>
<div className="xx-clone-detail-row">
<span className="xx-clone-detail-label">语言</span>
<span>{langText}</span>
</div>
<div className="xx-clone-detail-row">
<span className="xx-clone-detail-label">来源</span>
<span>{voice.sourceName}</span>
</div>
<div className="xx-clone-detail-row">
<span className="xx-clone-detail-label">创建时间</span>
<span>{voice.createdAt}</span>
</div>
{voice.errorMessage && (
<div
className="xx-clone-detail-row"
style={{ color: "var(--error-color, #ef4444)" }}
>
<span className="xx-clone-detail-label">错误</span>
<span>{voice.errorMessage}</span>
</div>
)}
</div>
<div className="xx-clone-detail-actions">
{voice.status === "failed" && (
<Button
buttonType="ghost"
buttonSize="sm"
icon={<ReloadOutlined />}
onClick={onRetry}
>
重试
</Button>
)}
<Button
buttonType="ghost"
buttonSize="sm"
icon={<DeleteOutlined />}
onClick={onDelete}
>
删除
</Button>
{voice.status === "ready" && (
<Button buttonType="primary" buttonSize="sm" onClick={onUse}>
使用此音色
</Button>
)}
</div>
</div>
</div>
);
};
/** 骨架屏 */
const CloneCardSkeleton: React.FC = () => (
<div className="xx-voice-card xx-skeleton-clone">
<div className="xx-skeleton-clone-avatar" />
<div className="xx-skeleton-clone-info">
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--name" />
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--status" />
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--desc" />
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--meta" />
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--footer" />
</div>
</div>
);
/* ── 辅助函数 ─────────────────────────────────────────── */
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const getAudioDuration = (file: File): Promise<number> =>
new Promise((resolve) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.addEventListener("loadedmetadata", () => {
resolve(audio.duration);
URL.revokeObjectURL(url);
});
audio.addEventListener("error", () => {
resolve(0);
URL.revokeObjectURL(url);
});
audio.src = url;
});
const buildVoiceMetadata = (data: {
gender?: string;
description?: string;
duration?: number;
}): Record<string, unknown> => {
const metadata: Record<string, unknown> = {};
if (data.gender) metadata.gender = data.gender;
if (data.description) metadata.description = data.description;
if (data.duration) metadata.duration = Math.round(data.duration);
return metadata;
};
/* ============================================================
* 主组件
* ============================================================ */
const VoiceLibrary: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabKey>("preset");
const [searchText, setSearchText] = useState("");
const [filterGender, setFilterGender] = useState<string>("all");
const [filterLang, setFilterLang] = useState<string>("all");
const [playingId, setPlayingId] = useState<string | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const intervalRef = useRef<number | null>(null);
const queryClient = useQueryClient();
const [detailVoice, setDetailVoice] = useState<ClonedVoiceDisplay | null>(
null,
);
const [toasts, setToasts] = useState<Toast[]>([]);
const [cloneModalOpen, setCloneModalOpen] = useState(false);
/* ── 上传音频弹窗状态 ── */
const [uploadOpen, setUploadOpen] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadName, setUploadName] = useState("");
const [uploadGender, setUploadGender] = useState<VoiceGender>("female");
const [uploadDesc, setUploadDesc] = useState("");
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
/* ── AI 配音弹窗状态 ── */
const [ttsOpen, setTtsOpen] = useState(false);
const [ttsText, setTtsText] = useState("");
const [ttsVoiceId, setTtsVoiceId] = useState<string>("");
const [ttsSpeed, setTtsSpeed] = useState(1.0);
const [ttsJobId, setTtsJobId] = useState<string | null>(null);
const [ttsStatus, setTtsStatus] = useState<
"idle" | "synthesizing" | "done" | "error"
>("idle");
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null);
const [ttsError, setTtsError] = useState<string | null>(null);
const ttsTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const showToast = useCallback((message: string, type: Toast["type"]) => {
const id = ++toastIdSeq;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3000);
}, []);
/** 删除克隆音色 */
const deleteMutation = useMutation({
mutationFn: deleteVoiceClone,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["voice-clones"] });
showToast("音色已删除", "success");
},
onError: () => {
showToast("删除失败", "error");
},
});
/** 重试克隆 */
const retryMutation = useMutation({
mutationFn: retryVoiceClone,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["voice-clones"] });
showToast("已重新提交克隆", "success");
},
onError: () => {
showToast("重试失败", "error");
},
});
/* ── 上传音频 mutation ── */
const uploadMutation = useMutation({
mutationFn: async (data: {
file: File;
name: string;
gender: VoiceGender;
description: string;
}) => {
setUploadProgress(0);
try {
/* 获取或创建默认配音素材库 */
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
});
const lib = libs.find((l) => l.kind === "voice");
if (!lib) throw new Error("配音素材库不存在,请先在配音素材库页面创建");
/* 直传文件 */
const { storage_key } = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
});
/* 获取音频时长 */
const duration = await getAudioDuration(data.file);
/* 创建素材记录 */
await createAsset({
library_id: lib.id,
name: data.name,
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildVoiceMetadata({
gender: data.gender,
description: data.description,
duration,
}),
});
} finally {
setUploadProgress(null);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
setUploadOpen(false);
setUploadFile(null);
setUploadName("");
setUploadDesc("");
showToast("上传成功", "success");
},
onError: (err: Error) => {
showToast(err.message || "上传失败,请重试", "error");
},
});
/* ── TTS 合成 ── */
const handleTtsSynthesize = useCallback(async () => {
if (!ttsText.trim()) {
message.warning("请输入要合成的文本");
return;
}
setTtsError(null);
setTtsStatus("synthesizing");
setTtsAudioUrl(null);
setTtsJobId(null);
try {
const resp = await synthesizeSpeech({
text: ttsText.trim(),
voice_id: ttsVoiceId || undefined,
speed: ttsSpeed,
});
setTtsJobId(resp.job_id);
/* 轮询状态 */
ttsTimerRef.current = setInterval(async () => {
try {
const job = await getTTSJobStatus(resp.job_id);
if (job.status === "completed") {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("done");
setTtsAudioUrl(job.output_audio_url);
} else if (job.status === "failed") {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("error");
setTtsError(job.error_message || "合成失败");
}
} catch {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("error");
setTtsError("查询合成状态失败");
}
}, 2000);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "合成请求失败";
setTtsStatus("error");
setTtsError(msg);
}
}, [ttsText, ttsVoiceId, ttsSpeed]);
/* ── TTS 保存到素材库 ── */
const handleTtsSave = useCallback(async () => {
if (!ttsJobId) return;
try {
await saveTtsToLibrary(ttsJobId, { name: ttsText.slice(0, 50) });
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
showToast("已保存到配音素材库", "success");
setTtsOpen(false);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "保存失败";
showToast(msg, "error");
}
}, [ttsJobId, ttsText, queryClient, showToast]);
/* ── TTS 定时器清理 ── */
useEffect(() => {
return () => {
if (ttsTimerRef.current) clearInterval(ttsTimerRef.current);
};
}, []);
/* ── 数据查询(任务 3.11:替换 Mock) ──────────────── */
/** 预置音色列表 */
const { data: presetData, isLoading: presetLoading } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
});
/** 克隆音色列表 */
const { data: cloneData, isLoading: cloneLoading } = useQuery({
queryKey: ["voice-clones"],
queryFn: () => getVoiceClonesWithTotal({ limit: 50 }),
});
/** 统一统计(preset_count / clone_count) */
const { data: unifiedStats } = useQuery({
queryKey: ["voices-unified"],
queryFn: () => fetchVoices({ limit: 1 }),
});
const presetVoices = useMemo(
() => (presetData?.items ?? []).map(mapPresetToDisplay),
[presetData],
);
const clonedVoices = useMemo(
() =>
(cloneData?.items ?? []).map((p) => mapCloneToDisplay(toVoiceClone(p))),
[cloneData],
);
const presetCount = unifiedStats?.preset_count ?? presetData?.total ?? 0;
const cloneCount = unifiedStats?.clone_count ?? cloneData?.total ?? 0;
const filteredPreset = useMemo(() => {
let list = presetVoices;
if (filterGender !== "all") {
list = list.filter((v) => v.gender === filterGender);
}
if (filterLang !== "all") {
list = list.filter((v) => v.language === filterLang);
}
if (searchText.trim()) {
const q = searchText.trim().toLowerCase();
list = list.filter(
(v) =>
v.name.toLowerCase().includes(q) ||
v.description.toLowerCase().includes(q) ||
v.tags.some((tag) => tag.toLowerCase().includes(q)),
);
}
return list;
}, [presetVoices, filterGender, filterLang, searchText]);
const handlePlay = useCallback(
(voiceId: string, duration: number) => {
if (playingId === voiceId) return;
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
setPlayingId(voiceId);
setCurrentTime(0);
intervalRef.current = window.setInterval(() => {
setCurrentTime((prev) => {
if (prev >= duration) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setPlayingId(null);
return 0;
}
return prev + 0.1;
});
}, 100);
},
[playingId],
);
const handlePause = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setPlayingId(null);
}, []);
const handleSeek = useCallback(
(voiceId: string, time: number, duration: number) => {
setCurrentTime(time);
if (playingId !== voiceId) {
handlePlay(voiceId, duration);
}
},
[playingId, handlePlay],
);
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
const handleToggleStar = (_voiceId: string) => {
// TODO: 后端暂未提供收藏接口
};
/** 克隆音色操作 handlers */
const handleCloneDelete = (voice: ClonedVoiceDisplay) => {
if (window.confirm(`确定删除音色「${voice.name}」吗?`)) {
deleteMutation.mutate(voice.id);
if (detailVoice?.id === voice.id) setDetailVoice(null);
}
};
const handleCloneRetry = (voice: ClonedVoiceDisplay) => {
retryMutation.mutate(voice.id);
};
const handleCloneUse = (_voice: ClonedVoiceDisplay) => {
showToast("已选择音色", "success");
};
const handleShowDetail = (voice: ClonedVoiceDisplay) => {
setDetailVoice(voice);
};
const handleClonePlayPause = (voiceId: string, duration: number) => {
if (playingId === voiceId) {
handlePause();
} else {
handlePlay(voiceId, duration);
}
};
/** 克隆音色成功回调 */
const handleCloneSuccess = (_voice: VoiceClone) => {
queryClient.invalidateQueries({ queryKey: ["voice-clones"] });
showToast("克隆已提交,正在生成中", "success");
};
const pageActions = (
<div className="xx-voices-actions">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<UploadOutlined />}
onClick={() => setUploadOpen(true)}
>
上传音频
</Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<AudioOutlined />}
onClick={() => setCloneModalOpen(true)}
>
克隆音色
</Button>
<Button
buttonType="primary"
buttonSize="sm"
icon={<RobotOutlined />}
onClick={() => setTtsOpen(true)}
>
AI 配音
</Button>
</div>
);
return (
<div className="xx-voices-page">
<PageHead
title="配音库"
description="预置音色与克隆音色管理,支持 CosyVoice 高质量 TTS"
actions={pageActions}
/>
<div className="xx-voices-tabs">
<button
className={`xx-voices-tab${activeTab === "preset" ? " active" : ""}`}
onClick={() => {
setActiveTab("preset");
handlePause();
}}
>
<AudioOutlined />
预置音色
<span className="xx-voices-tab-count">{presetCount}</span>
</button>
<button
className={`xx-voices-tab${activeTab === "cloned" ? " active" : ""}`}
onClick={() => {
setActiveTab("cloned");
handlePause();
}}
>
<UserOutlined />
我的克隆
<span className="xx-voices-tab-count">{cloneCount}</span>
</button>
</div>
{activeTab === "preset" && (
<div className="xx-voices-tab-content">
<div className="xx-voices-filters">
<Input
placeholder="搜索音色名称、标签..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ width: 260 }}
/>
<Select
value={filterGender}
onChange={setFilterGender}
style={{ width: 130 }}
options={[
{ value: "all", label: "全部音色" },
{ value: "male", label: "男声" },
{ value: "female", label: "女声" },
{ value: "child", label: "童声" },
{ value: "elderly", label: "老年" },
]}
/>
<Select
value={filterLang}
onChange={setFilterLang}
style={{ width: 130 }}
options={[
{ value: "all", label: "全部语言" },
{ value: "zh", label: "中文" },
{ value: "en", label: "英文" },
{ value: "ja", label: "日文" },
{ value: "ko", label: "韩文" },
]}
/>
</div>
{presetLoading && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon">
<SoundOutlined />
</div>
<p>加载预置音色中...</p>
</div>
)}
{!presetLoading && filteredPreset.length > 0 && (
<div className="xx-voice-grid">
{filteredPreset.map((voice) => (
<VoiceCard
key={voice.id}
id={voice.id}
name={voice.name}
subtitle={`${genderLabel(voice.gender)} · ${languageLabel(voice.language)} · ${voice.description}`}
tags={voice.tags}
duration={voice.duration}
gender={voice.gender}
isPlaying={playingId === voice.id}
isSelected={false}
currentTime={playingId === voice.id ? currentTime : 0}
starred={voice.starred}
onPlay={() => handlePlay(voice.id, voice.duration)}
onPause={handlePause}
onSeek={(time) => handleSeek(voice.id, time, voice.duration)}
onToggleStar={() => handleToggleStar(voice.voiceId)}
/>
))}
</div>
)}
{!presetLoading && filteredPreset.length === 0 && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon">
<SoundOutlined />
</div>
<p>未找到匹配的音色</p>
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => {
setSearchText("");
setFilterGender("all");
setFilterLang("all");
}}
>
清除筛选条件
</Button>
</div>
)}
</div>
)}
{activeTab === "cloned" && (
<div className="xx-voices-tab-content">
{/* 骨架屏加载 */}
{cloneLoading && (
<div className="xx-voice-grid">
{Array.from({ length: 6 }).map((_, i) => (
<CloneCardSkeleton key={i} />
))}
</div>
)}
{/* 卡片列表 */}
{!cloneLoading && clonedVoices.length > 0 && (
<div className="xx-voice-grid">
{clonedVoices.map((voice) => (
<CloneVoiceCard
key={voice.id}
voice={voice}
isPlaying={playingId === voice.id}
currentTime={playingId === voice.id ? currentTime : 0}
onPlay={() => handleClonePlayPause(voice.id, voice.duration)}
onPause={handlePause}
onUse={() => handleCloneUse(voice)}
onDelete={() => handleCloneDelete(voice)}
onRetry={() => handleCloneRetry(voice)}
onShowDetail={() => handleShowDetail(voice)}
/>
))}
</div>
)}
{/* 空状态 */}
{!cloneLoading && clonedVoices.length === 0 && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon">
<UserOutlined />
</div>
<h3>暂无克隆音色</h3>
<p>上传音频素材即可克隆专属音色</p>
<Button
buttonType="primary"
buttonSize="md"
onClick={() => setCloneModalOpen(true)}
>
<PlusOutlined /> 去克隆音色
</Button>
</div>
)}
{/* 处理中提示 */}
{!cloneLoading &&
clonedVoices.some((v) => v.status === "processing") && (
<div className="xx-clone-processing-hint">
<RobotOutlined />
<span>部分音色正在克隆处理中,完成后将自动出现在列表中。</span>
</div>
)}
</div>
)}
{/* 克隆音色弹窗 */}
<CloneModal
open={cloneModalOpen}
onClose={() => setCloneModalOpen(false)}
onSuccess={handleCloneSuccess}
/>
{/* 详情弹窗 */}
{detailVoice && (
<CloneDetailModal
voice={detailVoice}
onClose={() => setDetailVoice(null)}
onDelete={() => handleCloneDelete(detailVoice)}
onRetry={() => handleCloneRetry(detailVoice)}
onUse={() => handleCloneUse(detailVoice)}
/>
)}
{/* ── 上传音频弹窗 ── */}
<Modal
title="上传音频"
open={uploadOpen}
onCancel={() => {
if (uploadProgress !== null) return; // 上传中不可关闭
setUploadOpen(false);
setUploadFile(null);
setUploadName("");
setUploadDesc("");
}}
footer={null}
width={520}
maskClosable={uploadProgress === null}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
{/* 拖拽上传区 */}
<Upload.Dragger
accept="audio/*"
maxCount={1}
beforeUpload={(file) => {
setUploadFile(file);
if (!uploadName) setUploadName(file.name.replace(/\.[^.]+$/, ""));
return false;
}}
onRemove={() => {
setUploadFile(null);
setUploadProgress(null);
}}
showUploadList={false}
disabled={uploadProgress !== null}
>
<p
style={{
fontSize: 32,
color: "var(--primary-color)",
marginBottom: 8,
}}
>
<UploadOutlined />
</p>
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>
点击或拖拽音频文件到此处
</p>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
margin: 0,
}}
>
支持 MP3、WAV、AAC、FLAC 等格式,最大 200MB
</p>
</Upload.Dragger>
{/* 已选文件信息 */}
{uploadFile && (
<div
style={{
padding: "10px 12px",
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<SoundOutlined
style={{ fontSize: 18, color: "var(--primary-color)" }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{uploadFile.name}
</div>
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
{formatFileSize(uploadFile.size)}
</div>
</div>
</div>
)}
{/* 上传进度 */}
{uploadProgress !== null && (
<div style={{ textAlign: "center", padding: "8px 0" }}>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: "var(--primary-color)",
}}
>
{uploadProgress}%
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{uploadProgress < 100 ? "上传中..." : "处理中..."}
</div>
<div
style={{
height: 4,
background: "var(--bg-tertiary)",
borderRadius: 2,
marginTop: 8,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${uploadProgress}%`,
background: "var(--primary-color)",
borderRadius: 2,
transition: "width 0.3s ease",
}}
/>
</div>
</div>
)}
{/* 名称 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
素材名称
</div>
<input
value={uploadName}
onChange={(e) => setUploadName(e.target.value)}
placeholder="输入素材名称"
maxLength={100}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
/>
</div>
{/* 性别选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
音色性别
</div>
<div style={{ display: "flex", gap: 8 }}>
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
<button
key={g}
type="button"
onClick={() => setUploadGender(g)}
disabled={uploadProgress !== null}
style={{
flex: 1,
padding: "6px 0",
borderRadius: 8,
border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`,
background:
uploadGender === g
? "var(--primary-soft)"
: "transparent",
color:
uploadGender === g
? "var(--primary-color)"
: "var(--text-secondary)",
fontSize: 13,
fontWeight: uploadGender === g ? 600 : 400,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
transition: "all 0.2s",
}}
>
{g === "female" ? "女声" : g === "male" ? "男声" : "童声"}
</button>
))}
</div>
</div>
{/* 描述 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
音色描述(可选)
</div>
<textarea
value={uploadDesc}
onChange={(e) => setUploadDesc(e.target.value)}
placeholder="描述这个音色的特点..."
maxLength={500}
rows={2}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
}}
/>
</div>
{/* 操作按钮 */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 10,
paddingTop: 4,
}}
>
<button
type="button"
onClick={() => {
setUploadOpen(false);
setUploadFile(null);
setUploadName("");
setUploadDesc("");
}}
disabled={uploadProgress !== null}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "1px solid var(--border-color)",
background: "transparent",
fontSize: 13,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
color: "var(--text-secondary)",
}}
>
取消
</button>
<button
type="button"
onClick={() => {
if (!uploadFile) {
message.warning("请先选择音频文件");
return;
}
if (!uploadName.trim()) {
message.warning("请输入素材名称");
return;
}
uploadMutation.mutate({
file: uploadFile,
name: uploadName.trim(),
gender: uploadGender,
description: uploadDesc.trim(),
});
}}
disabled={
!uploadFile || !uploadName.trim() || uploadProgress !== null
}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "none",
background:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 13,
fontWeight: 600,
cursor:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "not-allowed"
: "pointer",
}}
>
{uploadProgress !== null ? "上传中..." : "开始上传"}
</button>
</div>
</div>
</Modal>
{/* ── AI 配音弹窗 ── */}
<Modal
title="AI 配音"
open={ttsOpen}
onCancel={() => {
setTtsOpen(false);
setTtsText("");
setTtsVoiceId("");
setTtsSpeed(1.0);
setTtsStatus("idle");
setTtsAudioUrl(null);
setTtsError(null);
setTtsJobId(null);
}}
footer={null}
width={560}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
{/* 文本输入 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
输入文本
</div>
<textarea
value={ttsText}
onChange={(e) => setTtsText(e.target.value)}
placeholder="输入要配音的文本内容..."
maxLength={2000}
rows={4}
style={{
width: "100%",
padding: "10px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
lineHeight: 1.6,
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary)",
textAlign: "right",
marginTop: 4,
}}
>
{ttsText.length}/2000
</div>
</div>
{/* 音色选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
选择音色
</div>
<select
value={ttsVoiceId}
onChange={(e) => setTtsVoiceId(e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
>
<option value="">默认音色</option>
{presetVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name} — {genderLabel(v.gender)}
</option>
))}
</select>
</div>
{/* 语速 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
语速:{ttsSpeed.toFixed(1)}x
</div>
<input
type="range"
min={0.5}
max={2.0}
step={0.1}
value={ttsSpeed}
onChange={(e) => setTtsSpeed(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "var(--primary-color)" }}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--text-tertiary)",
}}
>
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
{/* 合成按钮 */}
<button
type="button"
onClick={handleTtsSynthesize}
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
style={{
width: "100%",
padding: "10px 0",
borderRadius: 8,
border: "none",
background:
ttsStatus === "synthesizing" || !ttsText.trim()
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor:
ttsStatus === "synthesizing" || !ttsText.trim()
? "not-allowed"
: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<RobotOutlined />
{ttsStatus === "synthesizing" ? "合成中..." : "开始合成"}
</button>
{/* 错误提示 */}
{ttsError && (
<div
style={{
padding: "10px 12px",
background: "var(--error-soft, #fff2f0)",
borderRadius: 8,
color: "var(--error-color, #ff4d4f)",
fontSize: 13,
}}
>
{ttsError}
</div>
)}
{/* 合成结果 */}
{ttsStatus === "done" && ttsAudioUrl && (
<div
style={{
padding: 12,
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--success-color, #52c41a)",
}}
>
✅ 合成完成
</div>
<audio controls src={ttsAudioUrl} style={{ width: "100%" }} />
<button
type="button"
onClick={handleTtsSave}
style={{
padding: "8px 0",
borderRadius: 8,
border: "1px solid var(--primary-color)",
background: "var(--primary-soft)",
color: "var(--primary-color)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}}
>
保存到配音素材库
</button>
</div>
)}
</div>
</Modal>
{/* 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>
)}
</div>
);
};
export default VoiceLibrary;