From 894bb06b5cb618217c12c85e894b432ce9c5379f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 24 Jul 2026 20:31:38 +0800 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20VoiceMaterialLibrary=20Phase=20?= =?UTF-8?q?1=20-=20=E6=8A=BD=E7=A6=BB=E7=B1=BB=E5=9E=8B/=E5=B8=B8=E9=87=8F?= =?UTF-8?q?/=E5=B7=A5=E5=85=B7=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts: VoiceGender/ViewMode/VoiceMaterial/VoiceAssetMetadata + mapAssetToMaterial/buildMetadata - constants.tsx: MAX_CARD_TAGS/MAX_ROW_TAGS/TAG_VARIANTS/GENDER_OPTIONS - utils/format.ts: genderLabel/genderIcon/genderClass/formatDuration/formatFileSize/formatDate - utils/audio.ts: getAudioDuration - 主文件从 1966 行 → 1854 行 (-112行, -5.7%) - 清理未使用的 icon import (ManOutlined/WomanOutlined/UserOutlined) --- .../voice-materials/VoiceMaterialLibrary.tsx | 148 +++--------------- .../src/pages/voice-materials/constants.tsx | 23 +++ apps/web/src/pages/voice-materials/types.ts | 60 +++++++ .../src/pages/voice-materials/utils/audio.ts | 20 +++ .../src/pages/voice-materials/utils/format.ts | 32 ++++ 5 files changed, 153 insertions(+), 130 deletions(-) create mode 100644 apps/web/src/pages/voice-materials/constants.tsx create mode 100644 apps/web/src/pages/voice-materials/types.ts create mode 100644 apps/web/src/pages/voice-materials/utils/audio.ts create mode 100644 apps/web/src/pages/voice-materials/utils/format.ts diff --git a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx index 3e707206b..6ad44563f 100644 --- 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, @@ -49,133 +46,24 @@ import { type TagItem, getTags, createTag, tagAsset, untagAsset } from "@/api/ta import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts" import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices" 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 - }) -} +import { + type VoiceGender, + type ViewMode, + type VoiceMaterial, + type VoiceAssetMetadata, + 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" /* ============================================================ * 标签选择器组件(支持自定义新增 + 移除) 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..62f3043f8 --- /dev/null +++ b/apps/web/src/pages/voice-materials/constants.tsx @@ -0,0 +1,23 @@ +/** + * 配音素材库常量 + */ +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..7a766cb3e --- /dev/null +++ b/apps/web/src/pages/voice-materials/utils/format.ts @@ -0,0 +1,32 @@ +/** + * 格式化工具函数 + */ +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", + }) -- 2.54.0 From 6c01f975f9a7c6e29dc350c7c512ff9ba0efaf4b Mon Sep 17 00:00:00 2001 From: saas-frontend-bot Date: Fri, 24 Jul 2026 21:04:29 +0800 Subject: [PATCH 2/4] fix: remove unused type imports causing lint/ts errors --- apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx | 2 -- 1 file changed, 2 deletions(-) mode change 100644 => 100755 apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx 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 6ad44563f..6c63fe0c5 --- a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx +++ b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx @@ -40,7 +40,6 @@ 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" @@ -50,7 +49,6 @@ import { type VoiceGender, type ViewMode, type VoiceMaterial, - type VoiceAssetMetadata, mapAssetToMaterial, buildMetadata, } from "./types" -- 2.54.0 From aa04f2e3e6e551a2fd2ee8f58380b0c268e79040 Mon Sep 17 00:00:00 2001 From: saas-frontend-bot Date: Fri, 24 Jul 2026 22:09:33 +0800 Subject: [PATCH 3/4] fix: prettier formatting + import order --- apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx | 2 +- apps/web/src/pages/voice-materials/utils/format.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx index 6c63fe0c5..1e2ead7a0 100755 --- a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx +++ b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx @@ -44,7 +44,6 @@ import { 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 "./voice-materials.css" import { type VoiceGender, type ViewMode, @@ -62,6 +61,7 @@ import { formatDate, } from "./utils/format" import { getAudioDuration } from "./utils/audio" +import "./voice-materials.css" /* ============================================================ * 标签选择器组件(支持自定义新增 + 移除) diff --git a/apps/web/src/pages/voice-materials/utils/format.ts b/apps/web/src/pages/voice-materials/utils/format.ts index 7a766cb3e..ed529ea4a 100644 --- a/apps/web/src/pages/voice-materials/utils/format.ts +++ b/apps/web/src/pages/voice-materials/utils/format.ts @@ -4,8 +4,7 @@ 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 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 -- 2.54.0 From 13d12070113929bbf8a19a94e71e39c2936fa4c4 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 24 Jul 2026 22:30:02 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20lint=20?= =?UTF-8?q?=E8=AD=A6=E5=91=8A=20+=20=E6=B7=BB=E5=8A=A0=20format=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=87=BD=E6=95=B0=E5=8D=95=E5=85=83=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - constants.tsx: 添加 react-refresh/only-export-components 禁用注释 - 新增 format.test.ts: 为 format 工具函数添加单元测试(8 个用例) --- .../src/pages/voice-materials/constants.tsx | 1 + .../test/pages/voice-materials/format.test.ts | 75 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 apps/web/src/test/pages/voice-materials/format.test.ts diff --git a/apps/web/src/pages/voice-materials/constants.tsx b/apps/web/src/pages/voice-materials/constants.tsx index 62f3043f8..5babc19dd 100644 --- a/apps/web/src/pages/voice-materials/constants.tsx +++ b/apps/web/src/pages/voice-materials/constants.tsx @@ -1,6 +1,7 @@ /** * 配音素材库常量 */ +/* 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" 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}$/) + }) + }) +}) -- 2.54.0