diff --git a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx
old mode 100755
new mode 100644
index 8d481899f..2e0416579
--- a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx
+++ b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx
@@ -9,21 +9,8 @@
* - 删除素材
*/
import React from "react"
-import {
- AudioOutlined,
- SearchOutlined,
- PlusOutlined,
- DeleteOutlined,
- UploadOutlined,
- UnorderedListOutlined,
- AppstoreOutlined,
- CheckOutlined,
- TagsOutlined,
- RobotOutlined,
- LoadingOutlined,
-} from "@ant-design/icons"
-import { Button, Input, Select, Modal } from "@/components/ui"
-import { Popover, Popconfirm } from "antd"
+import { AudioOutlined, PlusOutlined, RobotOutlined } from "@ant-design/icons"
+import { Button, Modal } from "@/components/ui"
import PageHead from "@/components/layout/PageHead"
import { useVoiceMaterials } from "./hooks/useVoiceMaterials"
import { useAudioPlayer } from "./hooks/useAudioPlayer"
@@ -32,6 +19,11 @@ import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
import MaterialForm from "./components/MaterialForm"
import VoiceMaterialCard from "./components/VoiceMaterialCard"
import VoiceMaterialRow from "./components/VoiceMaterialRow"
+import Toolbar from "./components/Toolbar"
+import TagFilterBar from "./components/TagFilterBar"
+import BatchBar from "./components/BatchBar"
+import EmptyState from "./components/EmptyState"
+import TtsModal from "./components/TtsModal"
import "./voice-materials.css"
/* ============================================================
@@ -137,7 +129,7 @@ const VoiceMaterialLibrary: React.FC = () => {
})
}
- /* ── 渲染 ─────────────────────────────────────────────── */
+ const hasFilter = !!searchText || filterGender !== "all" || filterTagId !== "all"
const pageActions = (
@@ -164,155 +156,47 @@ const VoiceMaterialLibrary: React.FC = () => {
/>
{/* 工具栏:搜索 + 筛选 + 视图切换 */}
-
-
- }
- value={searchText}
- onChange={(e) => setSearchText(e.target.value)}
- allowClear
- style={{ width: 240 }}
- />
-
-
-
-
共 {filtered.length} 个素材
-
-
-
-
-
-
+
- {/* ── 标签筛选药丸条 ─────────────────────────────────── */}
- {tags.length > 0 && (
-
-
- {tags.map((tag) => (
-
- ))}
-
- )}
-
- {/* 加载中 */}
- {isLoading && (
-
- )}
+ {/* 标签筛选药丸条 */}
+
{/* 批量操作栏 */}
{batchMode && (
-
-
-
- {allSelected && }
-
-
- {allSelected ? "取消全选" : "全选"}
-
-
已选择 {selectedIds.size} 项
-
-
- }
- title="批量打标签"
- trigger="click"
- >
-
}>
- 批量打标签
-
-
-
- }>
- 批量删除
-
-
-
-
+
)}
+ {/* 加载/空状态 */}
+ setUploadOpen(true)}
+ />
+
{/* 内容区 — 卡片视图 */}
{!isLoading && filtered.length > 0 && viewMode === "card" && (
@@ -374,31 +258,6 @@ const VoiceMaterialLibrary: React.FC = () => {
)}
- {/* 空状态 */}
- {!isLoading && filtered.length === 0 && (
-
-
-
暂无配音素材
-
- {searchText || filterGender !== "all" || filterTagId !== "all"
- ? "未找到匹配的素材,试试调整筛选条件"
- : "上传音频文件,开始管理配音素材"}
-
- {!searchText && filterGender === "all" && filterTagId === "all" && (
-
}
- onClick={() => setUploadOpen(true)}
- >
- 上传配音
-
- )}
-
- )}
-
{/* 上传弹窗 */}
{
width={560}
destroyOnClose
>
-
- {/* 文本输入 */}
-
-
- {/* 音色选择 */}
-
-
-
-
-
- {/* 语速调节 */}
-
-
- setTtsSpeed(parseFloat(e.target.value))}
- style={{ width: "100%" }}
- />
-
-
- {/* 合成按钮 */}
-
:
}
- onClick={handleTtsSynthesize}
- disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
- >
- {ttsStatus === "synthesizing" ? "合成中…" : "开始合成"}
-
-
- {/* 错误提示 */}
- {ttsStatus === "error" && ttsError && (
-
- {ttsError}
-
- )}
-
- {/* 合成结果 */}
- {ttsStatus === "done" && ttsAudioUrl && (
-
-
-
}
- onClick={handleTtsSave}
- >
- 保存到配音库
-
-
- )}
-
+
)
diff --git a/apps/web/src/pages/voice-materials/components/BatchBar.tsx b/apps/web/src/pages/voice-materials/components/BatchBar.tsx
new file mode 100644
index 000000000..d6a251cc6
--- /dev/null
+++ b/apps/web/src/pages/voice-materials/components/BatchBar.tsx
@@ -0,0 +1,94 @@
+import React from "react"
+import { CheckOutlined, TagsOutlined, DeleteOutlined } from "@ant-design/icons"
+import { Button, Input } from "@/components/ui"
+import { Popover, Popconfirm } from "antd"
+import type { TagItem } from "@/api/tags"
+
+interface BatchBarProps {
+ selectedCount: number
+ allSelected: boolean
+ batchCustomTag: string
+ tags: TagItem[]
+ onSelectAll: () => void
+ onBatchCustomTagChange: (value: string) => void
+ onBatchCustomTagSubmit: (tag: string) => void
+ onBatchTag: (tagId: string) => void
+ onBatchDelete: () => void
+}
+
+const BatchBar: React.FC = ({
+ selectedCount,
+ allSelected,
+ batchCustomTag,
+ tags,
+ onSelectAll,
+ onBatchCustomTagChange,
+ onBatchCustomTagSubmit,
+ onBatchTag,
+ onBatchDelete,
+}) => {
+ return (
+
+
+
+ {allSelected && }
+
+
+ {allSelected ? "取消全选" : "全选"}
+
+
已选择 {selectedCount} 项
+
+
+ }
+ title="批量打标签"
+ trigger="click"
+ >
+
}>
+ 批量打标签
+
+
+
+ }>
+ 批量删除
+
+
+
+
+ )
+}
+
+export default BatchBar
diff --git a/apps/web/src/pages/voice-materials/components/EmptyState.tsx b/apps/web/src/pages/voice-materials/components/EmptyState.tsx
new file mode 100644
index 000000000..fda0a6569
--- /dev/null
+++ b/apps/web/src/pages/voice-materials/components/EmptyState.tsx
@@ -0,0 +1,52 @@
+import React from "react"
+import { AudioOutlined, UploadOutlined } from "@ant-design/icons"
+import { Button } from "@/components/ui"
+
+interface EmptyStateProps {
+ isLoading: boolean
+ isEmpty: boolean
+ hasFilter: boolean
+ onUploadClick: () => void
+}
+
+const EmptyState: React.FC = ({
+ isLoading,
+ isEmpty,
+ hasFilter,
+ onUploadClick,
+}) => {
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (!isEmpty) return null
+
+ return (
+
+
+
暂无配音素材
+
{hasFilter ? "未找到匹配的素材,试试调整筛选条件" : "上传音频文件,开始管理配音素材"}
+ {!hasFilter && (
+
}
+ onClick={onUploadClick}
+ >
+ 上传配音
+
+ )}
+
+ )
+}
+
+export default EmptyState
diff --git a/apps/web/src/pages/voice-materials/components/TagFilterBar.tsx b/apps/web/src/pages/voice-materials/components/TagFilterBar.tsx
new file mode 100644
index 000000000..dbeb51ab9
--- /dev/null
+++ b/apps/web/src/pages/voice-materials/components/TagFilterBar.tsx
@@ -0,0 +1,45 @@
+import React from "react"
+import type { TagItem } from "@/api/tags"
+
+interface TagFilterBarProps {
+ tags: TagItem[]
+ filterTagId: string
+ tagCountMap: Record
+ onTagSelect: (tagId: string) => void
+}
+
+const TagFilterBar: React.FC = ({
+ tags,
+ filterTagId,
+ tagCountMap,
+ onTagSelect,
+}) => {
+ if (tags.length === 0) return null
+
+ return (
+
+
+ {tags.map((tag) => (
+
+ ))}
+
+ )
+}
+
+export default TagFilterBar
diff --git a/apps/web/src/pages/voice-materials/components/Toolbar.tsx b/apps/web/src/pages/voice-materials/components/Toolbar.tsx
new file mode 100644
index 000000000..616468d2e
--- /dev/null
+++ b/apps/web/src/pages/voice-materials/components/Toolbar.tsx
@@ -0,0 +1,76 @@
+import React from "react"
+import { SearchOutlined, AppstoreOutlined, UnorderedListOutlined } from "@ant-design/icons"
+import { Input, Select } from "@/components/ui"
+import type { ViewMode } from "../types"
+
+interface ToolbarProps {
+ searchText: string
+ filterGender: string
+ viewMode: ViewMode
+ resultCount: number
+ onSearchChange: (value: string) => void
+ onGenderChange: (value: string) => void
+ onViewModeChange: (mode: ViewMode) => void
+}
+
+const GENDER_OPTIONS = [
+ { value: "all", label: "全部性别" },
+ { value: "male", label: "男声" },
+ { value: "female", label: "女声" },
+ { value: "child", label: "童声" },
+ { value: "neutral", label: "中性" },
+]
+
+const Toolbar: React.FC = ({
+ searchText,
+ filterGender,
+ viewMode,
+ resultCount,
+ onSearchChange,
+ onGenderChange,
+ onViewModeChange,
+}) => {
+ return (
+
+
+ }
+ value={searchText}
+ onChange={(e) => onSearchChange(e.target.value)}
+ allowClear
+ style={{ width: 240 }}
+ />
+
+
+
+
共 {resultCount} 个素材
+
+
+
+
+
+
+ )
+}
+
+export default Toolbar
diff --git a/apps/web/src/pages/voice-materials/components/TtsModal.tsx b/apps/web/src/pages/voice-materials/components/TtsModal.tsx
new file mode 100644
index 000000000..338c33bfb
--- /dev/null
+++ b/apps/web/src/pages/voice-materials/components/TtsModal.tsx
@@ -0,0 +1,171 @@
+import React from "react"
+import { RobotOutlined, LoadingOutlined, PlusOutlined } from "@ant-design/icons"
+import { Button } from "@/components/ui"
+
+export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
+
+export interface TtsPresetVoice {
+ voice_id: string
+ name: string
+}
+
+interface TtsModalProps {
+ open: boolean
+ text: string
+ voiceId: string
+ speed: number
+ status: TtsStatus
+ audioUrl: string
+ error: string
+ presetVoices: TtsPresetVoice[]
+ onClose: () => void
+ onTextChange: (value: string) => void
+ onVoiceChange: (voiceId: string) => void
+ onSpeedChange: (speed: number) => void
+ onSynthesize: () => void
+ onSave: () => void
+}
+
+const TtsModal: React.FC = ({
+ open,
+ text,
+ voiceId,
+ speed,
+ status,
+ audioUrl,
+ error,
+ presetVoices,
+ onClose,
+ onTextChange,
+ onVoiceChange,
+ onSpeedChange,
+ onSynthesize,
+ onSave,
+}) => {
+ if (!open) return null
+
+ const labelStyle: React.CSSProperties = {
+ fontSize: 13,
+ fontWeight: 500,
+ marginBottom: 6,
+ display: "block",
+ }
+
+ return (
+
+ {/* 文本输入 */}
+
+
+ {/* 音色选择 */}
+
+
+
+
+
+ {/* 语速调节 */}
+
+
+ onSpeedChange(parseFloat(e.target.value))}
+ style={{ width: "100%" }}
+ />
+
+
+ {/* 合成按钮 */}
+
:
}
+ onClick={onSynthesize}
+ disabled={status === "synthesizing" || !text.trim()}
+ >
+ {status === "synthesizing" ? "合成中…" : "开始合成"}
+
+
+ {/* 错误提示 */}
+ {status === "error" && error && (
+
+ {error}
+
+ )}
+
+ {/* 合成结果 */}
+ {status === "done" && audioUrl && (
+
+
+
} onClick={onSave}>
+ 保存到配音库
+
+
+ )}
+
+ )
+}
+
+export default TtsModal
diff --git a/apps/web/src/test/pages/voice-materials/smoke.test.tsx b/apps/web/src/test/pages/voice-materials/smoke.test.tsx
index 8dd1eaf4c..c43a86f65 100644
--- a/apps/web/src/test/pages/voice-materials/smoke.test.tsx
+++ b/apps/web/src/test/pages/voice-materials/smoke.test.tsx
@@ -13,6 +13,11 @@ import "@/pages/voice-materials/components/TagSelector"
import "@/pages/voice-materials/components/MaterialForm"
import "@/pages/voice-materials/components/VoiceMaterialCard"
import "@/pages/voice-materials/components/VoiceMaterialRow"
+import "@/pages/voice-materials/components/Toolbar"
+import "@/pages/voice-materials/components/TagFilterBar"
+import "@/pages/voice-materials/components/BatchBar"
+import "@/pages/voice-materials/components/EmptyState"
+import "@/pages/voice-materials/components/TtsModal"
// 工具函数
import "@/pages/voice-materials/utils/format"