diff --git a/apps/web/src/pages/voices/VoiceLibrary.tsx b/apps/web/src/pages/voices/VoiceLibrary.tsx old mode 100644 new mode 100755 index 6185d6717..99fae2e1a --- a/apps/web/src/pages/voices/VoiceLibrary.tsx +++ b/apps/web/src/pages/voices/VoiceLibrary.tsx @@ -11,21 +11,14 @@ 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 { message } from "antd" +import { Button } from "@/components/ui" import PageHead from "@/components/layout/PageHead" import { fetchPresetVoices, fetchVoices } from "@/api/voices" import { @@ -51,147 +44,18 @@ import { mapCloneToDisplay, buildVoiceMetadata, } from "@/pages/voices/types" -import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants" -import { - genderLabel, - languageLabel, - formatTime, - genderClass, - formatFileSize, -} from "@/pages/voices/utils/format" +import { genderLabel, languageLabel } from "@/pages/voices/utils/format" import { getAudioDuration } from "@/pages/voices/utils/audio" import CloneModal from "@/components/voice/CloneModal" +import VoiceCard from "@/pages/voices/components/VoiceCard" +import CloneVoiceCard from "@/pages/voices/components/CloneVoiceCard" +import CloneDetailModal from "@/pages/voices/components/CloneDetailModal" +import CloneCardSkeleton from "@/pages/voices/components/CloneCardSkeleton" +import UploadVoiceModal from "@/pages/voices/components/UploadVoiceModal" +import TtsModal, { type TtsStatus } from "@/pages/voices/components/TtsModal" +import VoiceFilterBar from "@/pages/voices/components/VoiceFilterBar" import "./voices.css" -/* ============================================================ - * 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 = ({ - id: _id, - name, - subtitle, - tags, - duration, - gender, - isPlaying, - isSelected, - currentTime, - starred, - status = "ready", - onPlay, - onPause, - onSeek, - onSelect, - onToggleStar, -}) => { - const progressRef = useRef(null) - - const handleProgressClick = (e: React.MouseEvent) => { - 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 ( -
-
- -
- -
-
-

- {name} -

- {starred !== undefined && ( - - )} -
-
{subtitle}
-
- {tags.slice(0, 3).map((tag) => ( - - {tag} - - ))} -
-
- - {status === "processing" && ( -
- - 处理中... -
- )} - {status === "failed" && ( -
克隆失败
- )} - - {status === "ready" &&
} - - {status === "ready" && ( -
- -
-
-
- - {isPlaying ? formatTime(currentTime) : formatTime(duration)} - -
- )} -
- ) -} - -/* ============================================================ - * 克隆音色卡片组件(任务 3.12) - * ============================================================ */ - -/** Toast 类型 */ interface Toast { id: number message: string @@ -200,268 +64,6 @@ interface Toast { 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 = ({ - 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 ( -
- {/* 右上角操作按钮 */} -
- - - - {isFailed && ( - - - - )} -
- - {/* 头部:头像 + 名称 + 状态 */} -
-
- -
-
-

- {voice.name} -

- - - {statusCfg.label} - -
-
- - {/* 描述 */} - {voice.description &&

{voice.description}

} - - {/* 元信息 */} -
- {(voice.gender || voice.language) && ( - - - {genderText} - {voice.language ? ` · ${voice.language}` : ""} - - )} - {voice.createdAt} -
- - {/* 错误信息 */} - {isFailed && voice.errorMessage && ( -
- - {voice.errorMessage} -
- )} - - {/* 底部操作区 */} -
- {voice.status === "ready" && ( - <> - -
-
-
- - - )} - {isProcessing && ( -
- - 克隆处理中,请稍候... -
- )} - {isFailed && ( - - )} -
-
- ) -} - -/** 克隆音色详情弹窗 */ -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 ( -
-
e.stopPropagation()}> - - -
-
- -
-
-

{voice.name}

- - - {statusCfg.label} - -
-
- - {voice.description &&

{voice.description}

} - -
-
- 性别 - {genderText} -
-
- 语言 - {langText} -
-
- 来源 - {voice.sourceName} -
-
- 创建时间 - {voice.createdAt} -
- {voice.errorMessage && ( -
- 错误 - {voice.errorMessage} -
- )} -
- -
- {voice.status === "failed" && ( - - )} - - {voice.status === "ready" && ( - - )} -
-
-
- ) -} - -/** 骨架屏 */ -const CloneCardSkeleton: React.FC = () => ( -
-
-
-
-
-
-
-
-
-
-) - -/* ============================================================ - * 主组件 - * ============================================================ */ const VoiceLibrary: React.FC = () => { const [activeTab, setActiveTab] = useState("preset") const [searchText, setSearchText] = useState("") @@ -491,7 +93,7 @@ const VoiceLibrary: React.FC = () => { const [ttsVoiceId, setTtsVoiceId] = useState("") const [ttsSpeed, setTtsSpeed] = useState(1.0) const [ttsJobId, setTtsJobId] = useState(null) - const [ttsStatus, setTtsStatus] = useState<"idle" | "synthesizing" | "done" | "error">("idle") + const [ttsStatus, setTtsStatus] = useState("idle") const [ttsAudioUrl, setTtsAudioUrl] = useState(null) const [ttsError, setTtsError] = useState(null) const ttsTimerRef = useRef | null>(null) @@ -874,40 +476,14 @@ const VoiceLibrary: React.FC = () => { {activeTab === "preset" && (
-
- } - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - allowClear - style={{ width: 260 }} - /> - -
+ {presetLoading && (
@@ -1104,313 +680,51 @@ const VoiceLibrary: React.FC = () => { )} {/* ── 上传音频弹窗 ── */} - { - if (uploadProgress !== null) return // 上传中不可关闭 + uploadFile={uploadFile} + uploadName={uploadName} + uploadGender={uploadGender} + uploadDesc={uploadDesc} + uploadProgress={uploadProgress} + onClose={() => { setUploadOpen(false) setUploadFile(null) setUploadName("") setUploadDesc("") }} - footer={null} - width={520} - maskClosable={uploadProgress === null} - > -
- {/* 拖拽上传区 */} - { - setUploadFile(file) - if (!uploadName) setUploadName(file.name.replace(/\.[^.]+$/, "")) - return false - }} - onRemove={() => { - setUploadFile(null) - setUploadProgress(null) - }} - showUploadList={false} - disabled={uploadProgress !== null} - > -

- -

-

- 点击或拖拽音频文件到此处 -

-

- 支持 MP3、WAV、AAC、FLAC 等格式,最大 200MB -

-
- - {/* 已选文件信息 */} - {uploadFile && ( -
- -
-
- {uploadFile.name} -
-
- {formatFileSize(uploadFile.size)} -
-
-
- )} - - {/* 上传进度 */} - {uploadProgress !== null && ( -
-
- {uploadProgress}% -
-
- {uploadProgress < 100 ? "上传中..." : "处理中..."} -
-
-
-
-
- )} - - {/* 名称 */} -
-
- 素材名称 -
- 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", - }} - /> -
- - {/* 性别选择 */} -
-
- 音色性别 -
-
- {(["female", "male", "child"] as VoiceGender[]).map((g) => ( - - ))} -
-
- - {/* 描述 */} -
-
- 音色描述(可选) -
-