diff --git a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx
old mode 100644
new mode 100755
index 3e707206b..1e2ead7a0
--- a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx
+++ b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx
@@ -23,9 +23,6 @@ import {
AppstoreOutlined,
CloseOutlined,
SoundOutlined,
- UserOutlined,
- ManOutlined,
- WomanOutlined,
CheckOutlined,
TagsOutlined,
MutedOutlined,
@@ -43,140 +40,29 @@ import {
uploadAssetDirect,
getAssetLibraries,
createAssetLibrary,
- type AssetItem,
} from "@/api/assets"
import { type TagItem, getTags, createTag, tagAsset, untagAsset } from "@/api/tags"
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices"
+import {
+ type VoiceGender,
+ type ViewMode,
+ type VoiceMaterial,
+ mapAssetToMaterial,
+ buildMetadata,
+} from "./types"
+import { MAX_CARD_TAGS, MAX_ROW_TAGS, TAG_VARIANTS, GENDER_OPTIONS } from "./constants"
+import {
+ genderLabel,
+ genderIcon,
+ genderClass,
+ formatDuration,
+ formatFileSize,
+ formatDate,
+} from "./utils/format"
+import { getAudioDuration } from "./utils/audio"
import "./voice-materials.css"
-/* ============================================================
- * 类型定义
- * ============================================================ */
-
-type VoiceGender = "male" | "female" | "child" | "neutral"
-type ViewMode = "card" | "list"
-
-/** 标签溢出限制 */
-const MAX_CARD_TAGS = 3
-const MAX_ROW_TAGS = 2
-const TAG_VARIANTS = ["info", "primary", "success", "warning", "error"] as const
-
-/** 性别选项 */
-const GENDER_OPTIONS: {
- value: VoiceGender
- label: string
- icon: React.ReactNode
-}[] = [
- { value: "male", label: "男声", icon: },
- { value: "female", label: "女声", icon: },
- { value: "child", label: "童声", icon: },
- { value: "neutral", label: "中性", icon: },
-]
-
-/** 前端配音素材数据模型(从 AssetItem 映射) */
-interface VoiceMaterial {
- id: string
- name: string
- description: string
- gender: VoiceGender
- tagIds: string[]
- fileName: string
- fileSize: number
- duration: number
- mimeType: string
- createdAt: string
- fileUrl?: string
-}
-
-/* ============================================================
- * 数据映射:AssetItem ↔ VoiceMaterial
- * ============================================================ */
-
-/** 后端 AssetItem → 前端 VoiceMaterial */
-const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => {
- const meta = asset.metadata || {}
-
- return {
- id: asset.id,
- name: asset.name,
- description: (meta.description as string) || "",
- gender: (meta.gender as VoiceGender) || "neutral",
- tagIds: Array.isArray(asset.tag_ids) ? asset.tag_ids : [],
- fileName: asset.storage_key?.split("/").pop() || asset.name,
- fileSize: asset.file_size || 0,
- duration: (meta.duration as number) || 0,
- mimeType: asset.mime_type || "audio/mpeg",
- createdAt: asset.created_at || new Date().toISOString(),
- fileUrl: asset.file_url,
- }
-}
-
-/** 配音素材上传元数据(传递给 createAsset 的 metadata) */
-interface VoiceAssetMetadata {
- gender: VoiceGender
- description: string
- duration: number
- [key: string]: unknown
-}
-
-/** 前端表单数据 → 后端 metadata(标签走独立 API,不再写 metadata.style) */
-const buildMetadata = (data: {
- gender: VoiceGender
- description: string
- duration?: number
-}): VoiceAssetMetadata => ({
- gender: data.gender,
- description: data.description,
- duration: data.duration || 0,
-})
-
-/* ============================================================
- * 工具函数
- * ============================================================ */
-
-const genderLabel = (g: VoiceGender) => GENDER_OPTIONS.find((o) => o.value === g)?.label ?? g
-
-const genderIcon = (g: VoiceGender) => GENDER_OPTIONS.find((o) => o.value === g)?.icon ?? null
-
-const genderClass = (g: VoiceGender) => `vmat-gender--${g}`
-
-const formatDuration = (seconds: number): string => {
- const m = Math.floor(seconds / 60)
- const s = Math.floor(seconds % 60)
- return `${m}:${s.toString().padStart(2, "0")}`
-}
-
-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 formatDate = (iso: string): string =>
- new Date(iso).toLocaleDateString("zh-CN", {
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- })
-
-/** 获取音频文件时长(秒) */
-const getAudioDuration = (file: File): Promise => {
- return 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
- })
-}
-
/* ============================================================
* 标签选择器组件(支持自定义新增 + 移除)
* ============================================================ */
diff --git a/apps/web/src/pages/voice-materials/constants.tsx b/apps/web/src/pages/voice-materials/constants.tsx
new file mode 100644
index 000000000..5babc19dd
--- /dev/null
+++ b/apps/web/src/pages/voice-materials/constants.tsx
@@ -0,0 +1,24 @@
+/**
+ * 配音素材库常量
+ */
+/* eslint-disable react-refresh/only-export-components */
+import React from "react"
+import { ManOutlined, WomanOutlined, UserOutlined, SoundOutlined } from "@ant-design/icons"
+import type { VoiceGender } from "./types"
+
+/** 标签溢出限制 */
+export const MAX_CARD_TAGS = 3
+export const MAX_ROW_TAGS = 2
+export const TAG_VARIANTS = ["info", "primary", "success", "warning", "error"] as const
+
+/** 性别选项 */
+export const GENDER_OPTIONS: {
+ value: VoiceGender
+ label: string
+ icon: React.ReactNode
+}[] = [
+ { value: "male", label: "男声", icon: },
+ { value: "female", label: "女声", icon: },
+ { value: "child", label: "童声", icon: },
+ { value: "neutral", label: "中性", icon: },
+]
diff --git a/apps/web/src/pages/voice-materials/types.ts b/apps/web/src/pages/voice-materials/types.ts
new file mode 100644
index 000000000..5438537d8
--- /dev/null
+++ b/apps/web/src/pages/voice-materials/types.ts
@@ -0,0 +1,60 @@
+/**
+ * 配音素材库类型定义
+ */
+import type { AssetItem } from "@/api/assets"
+
+export type VoiceGender = "male" | "female" | "child" | "neutral"
+export type ViewMode = "card" | "list"
+
+/** 前端配音素材数据模型(从 AssetItem 映射) */
+export interface VoiceMaterial {
+ id: string
+ name: string
+ description: string
+ gender: VoiceGender
+ tagIds: string[]
+ fileName: string
+ fileSize: number
+ duration: number
+ mimeType: string
+ createdAt: string
+ fileUrl?: string
+}
+
+/** 配音素材上传元数据(传递给 createAsset 的 metadata) */
+export interface VoiceAssetMetadata {
+ gender: VoiceGender
+ description: string
+ duration: number
+ [key: string]: unknown
+}
+
+/** 后端 AssetItem → 前端 VoiceMaterial */
+export const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => {
+ const meta = asset.metadata || {}
+
+ return {
+ id: asset.id,
+ name: asset.name,
+ description: (meta.description as string) || "",
+ gender: (meta.gender as VoiceGender) || "neutral",
+ tagIds: Array.isArray(asset.tag_ids) ? asset.tag_ids : [],
+ fileName: asset.storage_key?.split("/").pop() || asset.name,
+ fileSize: asset.file_size || 0,
+ duration: (meta.duration as number) || 0,
+ mimeType: asset.mime_type || "audio/mpeg",
+ createdAt: asset.created_at || new Date().toISOString(),
+ fileUrl: asset.file_url,
+ }
+}
+
+/** 前端表单数据 → 后端 metadata(标签走独立 API,不再写 metadata.style) */
+export const buildMetadata = (data: {
+ gender: VoiceGender
+ description: string
+ duration?: number
+}): VoiceAssetMetadata => ({
+ gender: data.gender,
+ description: data.description,
+ duration: data.duration || 0,
+})
diff --git a/apps/web/src/pages/voice-materials/utils/audio.ts b/apps/web/src/pages/voice-materials/utils/audio.ts
new file mode 100644
index 000000000..64f2624fb
--- /dev/null
+++ b/apps/web/src/pages/voice-materials/utils/audio.ts
@@ -0,0 +1,20 @@
+/**
+ * 音频相关工具函数
+ */
+
+/** 获取音频文件时长(秒) */
+export const getAudioDuration = (file: File): Promise => {
+ return 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
+ })
+}
diff --git a/apps/web/src/pages/voice-materials/utils/format.ts b/apps/web/src/pages/voice-materials/utils/format.ts
new file mode 100644
index 000000000..ed529ea4a
--- /dev/null
+++ b/apps/web/src/pages/voice-materials/utils/format.ts
@@ -0,0 +1,31 @@
+/**
+ * 格式化工具函数
+ */
+import { GENDER_OPTIONS } from "../constants"
+import type { VoiceGender } from "../types"
+
+export const genderLabel = (g: VoiceGender) => GENDER_OPTIONS.find((o) => o.value === g)?.label ?? g
+
+export const genderIcon = (g: VoiceGender) =>
+ GENDER_OPTIONS.find((o) => o.value === g)?.icon ?? null
+
+export const genderClass = (g: VoiceGender) => `vmat-gender--${g}`
+
+export const formatDuration = (seconds: number): string => {
+ const m = Math.floor(seconds / 60)
+ const s = Math.floor(seconds % 60)
+ return `${m}:${s.toString().padStart(2, "0")}`
+}
+
+export 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`
+}
+
+export const formatDate = (iso: string): string =>
+ new Date(iso).toLocaleDateString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ })
diff --git a/apps/web/src/test/pages/voice-materials/format.test.ts b/apps/web/src/test/pages/voice-materials/format.test.ts
new file mode 100644
index 000000000..4fcd5a5d8
--- /dev/null
+++ b/apps/web/src/test/pages/voice-materials/format.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from "vitest"
+import {
+ genderLabel,
+ genderIcon,
+ genderClass,
+ formatDuration,
+ formatFileSize,
+ formatDate,
+} from "@/pages/voice-materials/utils/format"
+
+describe("voice-materials format utils", () => {
+ describe("genderLabel", () => {
+ it("应返回正确的性别标签", () => {
+ expect(genderLabel("male")).toBe("男声")
+ expect(genderLabel("female")).toBe("女声")
+ expect(genderLabel("child")).toBe("童声")
+ expect(genderLabel("neutral")).toBe("中性")
+ })
+
+ it("未知性别返回原值", () => {
+ expect(genderLabel("unknown" as any)).toBe("unknown")
+ })
+ })
+
+ describe("genderIcon", () => {
+ it("应返回图标组件", () => {
+ expect(genderIcon("male")).toBeDefined()
+ expect(genderIcon("female")).toBeDefined()
+ expect(genderIcon("child")).toBeDefined()
+ expect(genderIcon("neutral")).toBeDefined()
+ })
+
+ it("未知性别返回 null", () => {
+ expect(genderIcon("unknown" as any)).toBeNull()
+ })
+ })
+
+ describe("genderClass", () => {
+ it("应返回正确的 CSS 类名", () => {
+ expect(genderClass("male")).toBe("vmat-gender--male")
+ expect(genderClass("female")).toBe("vmat-gender--female")
+ })
+ })
+
+ describe("formatDuration", () => {
+ it("应正确格式化秒数", () => {
+ expect(formatDuration(0)).toBe("0:00")
+ expect(formatDuration(5)).toBe("0:05")
+ expect(formatDuration(59)).toBe("0:59")
+ expect(formatDuration(60)).toBe("1:00")
+ expect(formatDuration(65)).toBe("1:05")
+ expect(formatDuration(125)).toBe("2:05")
+ expect(formatDuration(3600)).toBe("60:00")
+ })
+ })
+
+ describe("formatFileSize", () => {
+ it("应正确格式化文件大小", () => {
+ expect(formatFileSize(0)).toBe("0 B")
+ expect(formatFileSize(512)).toBe("512 B")
+ expect(formatFileSize(1024)).toBe("1.0 KB")
+ expect(formatFileSize(1536)).toBe("1.5 KB")
+ expect(formatFileSize(1024 * 1024)).toBe("1.0 MB")
+ expect(formatFileSize(1024 * 1024 * 2.5)).toBe("2.5 MB")
+ })
+ })
+
+ describe("formatDate", () => {
+ it("应格式化 ISO 日期字符串", () => {
+ const result = formatDate("2026-07-24T10:30:00.000Z")
+ // 格式应该是 YYYY/MM/DD 格式
+ expect(result).toMatch(/^\d{4}\/\d{2}\/\d{2}$/)
+ })
+ })
+})