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
- AssetLibrary.tsx - GeneratePage.tsx - VoiceLibrary.tsx This fixes the CI Frontend Lint job failure.
1761 lines
52 KiB
TypeScript
1761 lines
52 KiB
TypeScript
/**
|
||
* 配音库页面 — 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;
|