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 1fc379c5f..5f354e33e
--- a/apps/web/src/pages/voices/VoiceLibrary.tsx
+++ b/apps/web/src/pages/voices/VoiceLibrary.tsx
@@ -1,47 +1,39 @@
/**
* 配音库页面 — V21 设计系统
*
- * Phase 3: 逻辑抽离为 Hooks
- * - useVoicesData: 三 Tab 数据查询 + 筛选
- * - useAudioPlayer: 播放控制
- * - useCloneOperations: 克隆音色删除/重试/详情
- * - useTtsSynthesize: AI 配音合成
- * - useVoiceUpload: 上传音频
+ * 主组件仅保留 Hook 组装与整体布局
+ * 数据查询 → hooks/useVoicesData
+ * 播放控制 → hooks/useAudioPlayer
+ * 克隆操作 → hooks/useCloneOperations
+ * TTS 合成 → hooks/useTtsSynthesize
+ * 上传音频 → hooks/useVoiceUpload
+ * Tab 切换 → components/VoiceTabBar
+ * 预置音色 → components/PresetVoiceTab
+ * 克隆音色 → components/ClonedVoiceTab
+ * 配音素材 → components/MaterialVoiceTab
+ * 弹窗集合 → components/VoiceModals
+ * Toast 提示 → components/VoiceToasts
*/
import React, { useCallback, useState } from "react"
-import {
- SoundOutlined,
- PlusOutlined,
- RobotOutlined,
- UploadOutlined,
- AudioOutlined,
- UserOutlined,
-} from "@ant-design/icons"
+import { UploadOutlined, AudioOutlined, RobotOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import PageHead from "@/components/layout/PageHead"
import { type AssetItem } from "@/api/assets"
-import { genderLabel, languageLabel } from "@/pages/voices/utils/format"
-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 from "@/pages/voices/components/TtsModal"
-import VoiceFilterBar from "@/pages/voices/components/VoiceFilterBar"
-import { useVoicesData } from "@/pages/voices/hooks/useVoicesData"
-import { useAudioPlayer } from "@/pages/voices/hooks/useAudioPlayer"
-import { useCloneOperations } from "@/pages/voices/hooks/useCloneOperations"
-import { useTtsSynthesize } from "@/pages/voices/hooks/useTtsSynthesize"
-import { useVoiceUpload } from "@/pages/voices/hooks/useVoiceUpload"
+import VoiceTabBar from "./components/VoiceTabBar"
+import type { VoiceTabKey } from "./components/VoiceTabBar"
+import PresetVoiceTab from "./components/PresetVoiceTab"
+import ClonedVoiceTab from "./components/ClonedVoiceTab"
+import MaterialVoiceTab from "./components/MaterialVoiceTab"
+import VoiceModals from "./components/VoiceModals"
+import VoiceToasts from "./components/VoiceToasts"
+import type { Toast } from "./components/VoiceToasts"
+import { useVoicesData } from "./hooks/useVoicesData"
+import { useAudioPlayer } from "./hooks/useAudioPlayer"
+import { useCloneOperations } from "./hooks/useCloneOperations"
+import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
+import { useVoiceUpload } from "./hooks/useVoiceUpload"
import "./voices.css"
-interface Toast {
- id: number
- message: string
- type: "success" | "error"
-}
-
let toastIdSeq = 0
const VoiceLibrary: React.FC = () => {
@@ -138,17 +130,9 @@ const VoiceLibrary: React.FC = () => {
handleUploadClose,
} = useVoiceUpload({ showToast })
- // ── 克隆音色播放切换 ──────────────────────────────────
- const handleClonePlayPause = useCallback(
- (voiceId: string, duration: number) => {
- handleTogglePlay(voiceId, duration)
- },
- [handleTogglePlay],
- )
-
// ── 切换 Tab 时停止播放 ───────────────────────────────
const handleTabChange = useCallback(
- (tab: typeof activeTab) => {
+ (tab: VoiceTabKey) => {
setActiveTab(tab)
stopPlayback()
},
@@ -200,254 +184,85 @@ const VoiceLibrary: React.FC = () => {
actions={pageActions}
/>
- {/* ── Tabs ──────────────────────────────────────── */}
-
-
-
-
-
+ {/* ── Tab 切换栏 ────────────────────────────────── */}
+
{/* ── 预置音色 ──────────────────────────────────── */}
{activeTab === "preset" && (
-
-
-
- {presetLoading && (
-
- )}
-
- {!presetLoading && filteredPreset.length > 0 && (
-
- {filteredPreset.map((voice) => (
- handlePlay(voice.id, voice.duration)}
- onPause={handlePause}
- onSeek={(time) => handleSeek(voice.id, time, voice.duration)}
- onToggleStar={() => {}}
- />
- ))}
-
- )}
-
- {!presetLoading && filteredPreset.length === 0 && (
-
-
-
-
-
未找到匹配的音色
-
-
- )}
-
+
)}
{/* ── 我的克隆 ──────────────────────────────────── */}
{activeTab === "cloned" && (
-
- {/* 骨架屏加载 */}
- {cloneLoading && (
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
- )}
-
- {/* 卡片列表 */}
- {!cloneLoading && clonedVoices.length > 0 && (
-
- {clonedVoices.map((voice) => (
- handleClonePlayPause(voice.id, voice.duration)}
- onPause={handlePause}
- onUse={() => handleCloneUse(voice)}
- onDelete={() => handleCloneDelete(voice)}
- onRetry={() => handleCloneRetry(voice)}
- onShowDetail={() => handleShowDetail(voice)}
- />
- ))}
-
- )}
-
- {/* 空状态 */}
- {!cloneLoading && clonedVoices.length === 0 && (
-
-
-
-
-
暂无克隆音色
-
上传音频素材即可克隆专属音色
-
-
- )}
-
- {/* 处理中提示 */}
- {!cloneLoading && clonedVoices.some((v) => v.status === "processing") && (
-
-
- 部分音色正在克隆处理中,完成后将自动出现在列表中。
-
- )}
-
+ setCloneModalOpen(true)}
+ />
)}
{/* ── 配音素材 ──────────────────────────────────── */}
{activeTab === "material" && (
-
- {/* 骨架屏加载 */}
- {materialLoading && (
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
- )}
-
- {/* 卡片列表 */}
- {!materialLoading && materials.length > 0 && (
-
- {materials.map((asset: AssetItem) => {
- const duration = (asset.metadata?.duration as number) || 0
- const minutes = Math.floor(duration / 60)
- const seconds = Math.floor(duration % 60)
- return (
-
-
-
-
- {minutes}:{seconds.toString().padStart(2, "0")}
-
-
-
-
- {asset.name}
-
-
-
- {asset.file_size
- ? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB`
- : "--"}
-
-
-
-
- )
- })}
-
- )}
-
- {/* 空状态 */}
- {!materialLoading && materials.length === 0 && (
-
-
-
-
-
暂无配音素材
-
上传您的音频素材,用于视频配音
-
-
- )}
-
- )}
-
- {/* ── 克隆音色弹窗 ──────────────────────────────── */}
- setCloneModalOpen(false)}
- onSuccess={handleCloneSuccess}
- />
-
- {/* ── 详情弹窗 ──────────────────────────────────── */}
- {detailVoice && (
- handleCloneDelete(detailVoice)}
- onRetry={() => handleCloneRetry(detailVoice)}
- onUse={() => handleCloneUse(detailVoice)}
+ setUploadOpen(true)}
/>
)}
- {/* ── 上传音频弹窗 ──────────────────────────────── */}
- setCloneModalOpen(false)}
+ onCloneSuccess={handleCloneSuccess}
+ detailVoice={detailVoice}
+ onDetailClose={handleCloseDetail}
+ onDetailDelete={() => detailVoice && handleCloneDelete(detailVoice)}
+ onDetailRetry={() => detailVoice && handleCloneRetry(detailVoice)}
+ onDetailUse={() => detailVoice && handleCloneUse(detailVoice)}
+ uploadOpen={uploadOpen}
uploadFile={uploadFile}
uploadName={uploadName}
uploadGender={uploadGender}
uploadDesc={uploadDesc}
uploadProgress={uploadProgress}
- onClose={handleUploadClose}
+ onUploadClose={handleUploadClose}
onFileSelect={handleFileSelect}
onFileRemove={handleFileRemove}
onNameChange={setUploadName}
onGenderChange={setUploadGender}
onDescChange={setUploadDesc}
onUpload={handleUpload}
- />
-
- {/* ── AI 配音弹窗 ───────────────────────────────── */}
- {
ttsAudioUrl={ttsAudioUrl}
ttsError={ttsError}
presetVoices={presetVoices}
- onClose={handleTtsClose}
- onTextChange={setTtsText}
- onVoiceChange={setTtsVoiceId}
- onSpeedChange={setTtsSpeed}
- onSynthesize={handleTtsSynthesize}
- onSave={handleTtsSave}
+ onTtsClose={handleTtsClose}
+ onTtsTextChange={setTtsText}
+ onTtsVoiceChange={setTtsVoiceId}
+ onTtsSpeedChange={setTtsSpeed}
+ onTtsSynthesize={handleTtsSynthesize}
+ onTtsSave={handleTtsSave}
/>
{/* ── Toast 提示 ────────────────────────────────── */}
- {toasts.length > 0 && (
-
- {toasts.map((t) => (
-
- {t.type === "success" ? "✅" : "❌"} {t.message}
-
- ))}
-
- )}
+
)
}
diff --git a/apps/web/src/pages/voices/components/ClonedVoiceTab.tsx b/apps/web/src/pages/voices/components/ClonedVoiceTab.tsx
new file mode 100755
index 000000000..a3c578091
--- /dev/null
+++ b/apps/web/src/pages/voices/components/ClonedVoiceTab.tsx
@@ -0,0 +1,94 @@
+/**
+ * VoiceLibrary 我的克隆 Tab 内容
+ */
+import React from "react"
+import { UserOutlined, PlusOutlined, RobotOutlined } from "@ant-design/icons"
+import { Button } from "@/components/ui"
+import CloneVoiceCard from "./CloneVoiceCard"
+import CloneCardSkeleton from "./CloneCardSkeleton"
+import type { ClonedVoiceDisplay } from "../types"
+
+export interface ClonedVoiceTabProps {
+ loading: boolean
+ voices: ClonedVoiceDisplay[]
+ playingId: string | null
+ currentTime: number
+ onPlay: (voiceId: string, duration: number) => void
+ onPause: () => void
+ onUse: (voice: ClonedVoiceDisplay) => void
+ onDelete: (voice: ClonedVoiceDisplay) => void
+ onRetry: (voice: ClonedVoiceDisplay) => void
+ onShowDetail: (voice: ClonedVoiceDisplay) => void
+ onOpenClone: () => void
+}
+
+export const ClonedVoiceTab: React.FC = ({
+ loading,
+ voices,
+ playingId,
+ currentTime,
+ onPlay,
+ onPause,
+ onUse,
+ onDelete,
+ onRetry,
+ onShowDetail,
+ onOpenClone,
+}) => {
+ return (
+
+ {/* 骨架屏加载 */}
+ {loading && (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {/* 卡片列表 */}
+ {!loading && voices.length > 0 && (
+
+ {voices.map((voice) => (
+ onPlay(voice.id, voice.duration)}
+ onPause={onPause}
+ onUse={() => onUse(voice)}
+ onDelete={() => onDelete(voice)}
+ onRetry={() => onRetry(voice)}
+ onShowDetail={() => onShowDetail(voice)}
+ />
+ ))}
+
+ )}
+
+ {/* 空状态 */}
+ {!loading && voices.length === 0 && (
+
+
+
+
+
暂无克隆音色
+
上传音频素材即可克隆专属音色
+
+
+ )}
+
+ {/* 处理中提示 */}
+ {!loading && voices.some((v) => v.status === "processing") && (
+
+
+ 部分音色正在克隆处理中,完成后将自动出现在列表中。
+
+ )}
+
+ )
+}
+
+export default ClonedVoiceTab
diff --git a/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx b/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx
new file mode 100644
index 000000000..74c1dc444
--- /dev/null
+++ b/apps/web/src/pages/voices/components/MaterialVoiceTab.tsx
@@ -0,0 +1,85 @@
+/**
+ * VoiceLibrary 配音素材 Tab 内容
+ */
+import React from "react"
+import { SoundOutlined, UploadOutlined, AudioOutlined } from "@ant-design/icons"
+import { Button } from "@/components/ui"
+import type { AssetItem } from "@/api/assets"
+
+export interface MaterialVoiceTabProps {
+ loading: boolean
+ materials: AssetItem[]
+ onOpenUpload: () => void
+}
+
+export const MaterialVoiceTab: React.FC = ({
+ loading,
+ materials,
+ onOpenUpload,
+}) => {
+ return (
+
+ {/* 骨架屏加载 */}
+ {loading && (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {/* 卡片列表 */}
+ {!loading && materials.length > 0 && (
+
+ {materials.map((asset: AssetItem) => {
+ const duration = (asset.metadata?.duration as number) || 0
+ const minutes = Math.floor(duration / 60)
+ const seconds = Math.floor(duration % 60)
+ return (
+
+
+
+
+ {minutes}:{seconds.toString().padStart(2, "0")}
+
+
+
+
+ {asset.name}
+
+
+
+ {asset.file_size ? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB` : "--"}
+
+
+
+
+ )
+ })}
+
+ )}
+
+ {/* 空状态 */}
+ {!loading && materials.length === 0 && (
+
+
+
+
+
暂无配音素材
+
上传您的音频素材,用于视频配音
+
+
+ )}
+
+ )
+}
+
+export default MaterialVoiceTab
diff --git a/apps/web/src/pages/voices/components/PresetVoiceTab.tsx b/apps/web/src/pages/voices/components/PresetVoiceTab.tsx
new file mode 100644
index 000000000..7399601e5
--- /dev/null
+++ b/apps/web/src/pages/voices/components/PresetVoiceTab.tsx
@@ -0,0 +1,104 @@
+/**
+ * VoiceLibrary 预置音色 Tab 内容
+ */
+import React from "react"
+import { SoundOutlined } from "@ant-design/icons"
+import { Button } from "@/components/ui"
+import VoiceFilterBar from "./VoiceFilterBar"
+import VoiceCard from "./VoiceCard"
+import { genderLabel, languageLabel } from "../utils/format"
+import type { PresetVoiceDisplay } from "../types"
+
+export interface PresetVoiceTabProps {
+ searchText: string
+ filterGender: string
+ filterLang: string
+ onSearchChange: (text: string) => void
+ onGenderChange: (gender: string) => void
+ onLangChange: (lang: string) => void
+ loading: boolean
+ voices: PresetVoiceDisplay[]
+ playingId: string | null
+ currentTime: number
+ onPlay: (id: string, duration: number) => void
+ onPause: () => void
+ onSeek: (id: string, time: number, duration: number) => void
+ onClearFilters: () => void
+}
+
+export const PresetVoiceTab: React.FC = ({
+ searchText,
+ filterGender,
+ filterLang,
+ onSearchChange,
+ onGenderChange,
+ onLangChange,
+ loading,
+ voices,
+ playingId,
+ currentTime,
+ onPlay,
+ onPause,
+ onSeek,
+ onClearFilters,
+}) => {
+ return (
+
+
+
+ {loading && (
+
+ )}
+
+ {!loading && voices.length > 0 && (
+
+ {voices.map((voice) => (
+ onPlay(voice.id, voice.duration)}
+ onPause={onPause}
+ onSeek={(time) => onSeek(voice.id, time, voice.duration)}
+ onToggleStar={() => {}}
+ />
+ ))}
+
+ )}
+
+ {!loading && voices.length === 0 && (
+
+
+
+
+
未找到匹配的音色
+
+
+ )}
+
+ )
+}
+
+export default PresetVoiceTab
diff --git a/apps/web/src/pages/voices/components/VoiceModals.tsx b/apps/web/src/pages/voices/components/VoiceModals.tsx
new file mode 100644
index 000000000..9198a3686
--- /dev/null
+++ b/apps/web/src/pages/voices/components/VoiceModals.tsx
@@ -0,0 +1,149 @@
+/**
+ * VoiceLibrary 弹窗集合
+ */
+import React from "react"
+import type { ClonedVoiceDisplay, PresetVoiceDisplay, VoiceGender } from "../types"
+import type { VoiceClone } from "@/api/voice-clone"
+import type { TtsStatus } from "./TtsModal"
+import CloneModal from "@/components/voice/CloneModal"
+import CloneDetailModal from "./CloneDetailModal"
+import UploadVoiceModal from "./UploadVoiceModal"
+import TtsModal from "./TtsModal"
+
+export interface VoiceModalsProps {
+ /* 克隆音色弹窗 */
+ cloneModalOpen: boolean
+ onCloneClose: () => void
+ onCloneSuccess: (voice: VoiceClone) => void
+
+ /* 克隆详情弹窗 */
+ detailVoice: ClonedVoiceDisplay | null
+ onDetailClose: () => void
+ onDetailDelete: () => void
+ onDetailRetry: () => void
+ onDetailUse: () => void
+
+ /* 上传音频弹窗 */
+ uploadOpen: boolean
+ uploadFile: File | null
+ uploadName: string
+ uploadGender: VoiceGender
+ uploadDesc: string
+ uploadProgress: number | null
+ onUploadClose: () => void
+ onFileSelect: (file: File) => void
+ onFileRemove: () => void
+ onNameChange: (name: string) => void
+ onGenderChange: (gender: VoiceGender) => void
+ onDescChange: (desc: string) => void
+ onUpload: () => void
+
+ /* TTS 弹窗 */
+ ttsOpen: boolean
+ ttsText: string
+ ttsVoiceId: string
+ ttsSpeed: number
+ ttsStatus: TtsStatus
+ ttsAudioUrl: string | null
+ ttsError: string | null
+ presetVoices: PresetVoiceDisplay[]
+ onTtsClose: () => void
+ onTtsTextChange: (text: string) => void
+ onTtsVoiceChange: (id: string) => void
+ onTtsSpeedChange: (speed: number) => void
+ onTtsSynthesize: () => void
+ onTtsSave: () => void
+}
+
+export const VoiceModals: React.FC = ({
+ cloneModalOpen,
+ onCloneClose,
+ onCloneSuccess,
+ detailVoice,
+ onDetailClose,
+ onDetailDelete,
+ onDetailRetry,
+ onDetailUse,
+ uploadOpen,
+ uploadFile,
+ uploadName,
+ uploadGender,
+ uploadDesc,
+ uploadProgress,
+ onUploadClose,
+ onFileSelect,
+ onFileRemove,
+ onNameChange,
+ onGenderChange,
+ onDescChange,
+ onUpload,
+ ttsOpen,
+ ttsText,
+ ttsVoiceId,
+ ttsSpeed,
+ ttsStatus,
+ ttsAudioUrl,
+ ttsError,
+ presetVoices,
+ onTtsClose,
+ onTtsTextChange,
+ onTtsVoiceChange,
+ onTtsSpeedChange,
+ onTtsSynthesize,
+ onTtsSave,
+}) => {
+ return (
+ <>
+ {/* 克隆音色弹窗 */}
+
+
+ {/* 详情弹窗 */}
+ {detailVoice && (
+
+ )}
+
+ {/* 上传音频弹窗 */}
+
+
+ {/* AI 配音弹窗 */}
+
+ >
+ )
+}
+
+export default VoiceModals
diff --git a/apps/web/src/pages/voices/components/VoiceTabBar.tsx b/apps/web/src/pages/voices/components/VoiceTabBar.tsx
new file mode 100644
index 000000000..ea9ec296c
--- /dev/null
+++ b/apps/web/src/pages/voices/components/VoiceTabBar.tsx
@@ -0,0 +1,54 @@
+/**
+ * VoiceLibrary Tab 切换栏
+ */
+import React from "react"
+import { AudioOutlined, UserOutlined, SoundOutlined } from "@ant-design/icons"
+
+export type VoiceTabKey = "preset" | "cloned" | "material"
+
+export interface VoiceTabBarProps {
+ activeTab: VoiceTabKey
+ presetCount: number
+ cloneCount: number
+ materialCount: number
+ onTabChange: (tab: VoiceTabKey) => void
+}
+
+export const VoiceTabBar: React.FC = ({
+ activeTab,
+ presetCount,
+ cloneCount,
+ materialCount,
+ onTabChange,
+}) => {
+ return (
+
+
+
+
+
+ )
+}
+
+export default VoiceTabBar
diff --git a/apps/web/src/pages/voices/components/VoiceToasts.tsx b/apps/web/src/pages/voices/components/VoiceToasts.tsx
new file mode 100644
index 000000000..4b6857886
--- /dev/null
+++ b/apps/web/src/pages/voices/components/VoiceToasts.tsx
@@ -0,0 +1,29 @@
+/**
+ * VoiceLibrary Toast 提示
+ */
+import React from "react"
+
+export interface Toast {
+ id: number
+ message: string
+ type: "success" | "error"
+}
+
+export interface VoiceToastsProps {
+ toasts: Toast[]
+}
+
+export const VoiceToasts: React.FC = ({ toasts }) => {
+ if (toasts.length === 0) return null
+ return (
+
+ {toasts.map((t) => (
+
+ {t.type === "success" ? "✅" : "❌"} {t.message}
+
+ ))}
+
+ )
+}
+
+export default VoiceToasts