refactor: VoiceMaterialLibrary Phase 1 - 抽离类型/常量/工具函数 #846
Regular → Executable
+17
-131
@@ -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}$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user