refactor: VoiceMaterialLibrary Phase 1 - 抽离类型/常量/工具函数 #846

Merged
xiaoxia merged 4 commits from refactor/voice-material-library-phase1 into develop 2026-07-24 23:57:24 +08:00
6 changed files with 227 additions and 131 deletions
+17 -131
View File
@@ -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: <ManOutlined /> },
{ value: "female", label: "女声", icon: <WomanOutlined /> },
{ value: "child", label: "童声", icon: <UserOutlined /> },
{ value: "neutral", label: "中性", icon: <SoundOutlined /> },
]
/** 前端配音素材数据模型(从 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<number> => {
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
})
}
/* ============================================================
* 标签选择器组件(支持自定义新增 + 移除)
* ============================================================ */
@@ -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: <ManOutlined /> },
{ value: "female", label: "女声", icon: <WomanOutlined /> },
{ value: "child", label: "童声", icon: <UserOutlined /> },
{ value: "neutral", label: "中性", icon: <SoundOutlined /> },
]
@@ -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,
})
@@ -0,0 +1,20 @@
/**
* 音频相关工具函数
*/
/** 获取音频文件时长(秒) */
export const getAudioDuration = (file: File): Promise<number> => {
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
})
}
@@ -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",
})
@@ -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}$/)
})
})
})