From 94bfdb8be4b59fedd127c8185fe9870a69c40751 Mon Sep 17 00:00:00 2001 From: saas-frontend-bot Date: Sat, 25 Jul 2026 03:28:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20VoiceMaterialLibrary=20Phase=20?= =?UTF-8?q?3=20-=20=E6=8A=BD=E7=A6=BB4=E4=B8=AA=E4=B8=9A=E5=8A=A1=E9=80=BB?= =?UTF-8?q?=E8=BE=91Hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 VoiceMaterialLibrary.tsx 中抽离以下自定义 Hook: - useVoiceMaterials: 素材列表查询、筛选状态、增删改操作 - useAudioPlayer: 音频播放控制(播放/暂停/进度/音量) - useBatchOperations: 批量选择、批量删除、批量打标签 - useTtsSynthesize: TTS 合成状态管理、轮询、保存到素材库 主文件从 1108 行减少到 603 行,专注 UI 渲染组装。 新增 useAudioPlayer 单元测试。 --- .../voice-materials/VoiceMaterialLibrary.tsx | 689 +++--------------- .../voice-materials/hooks/useAudioPlayer.ts | 142 ++++ .../hooks/useBatchOperations.ts | 132 ++++ .../voice-materials/hooks/useTtsSynthesize.ts | 135 ++++ .../hooks/useVoiceMaterials.ts | 351 +++++++++ .../hooks/useAudioPlayer.test.ts | 148 ++++ 6 files changed, 1000 insertions(+), 597 deletions(-) create mode 100644 apps/web/src/pages/voice-materials/hooks/useAudioPlayer.ts create mode 100644 apps/web/src/pages/voice-materials/hooks/useBatchOperations.ts create mode 100644 apps/web/src/pages/voice-materials/hooks/useTtsSynthesize.ts create mode 100644 apps/web/src/pages/voice-materials/hooks/useVoiceMaterials.ts create mode 100644 apps/web/src/test/pages/voice-materials/hooks/useAudioPlayer.test.ts diff --git a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx index a6a327d8d..8d481899f 100755 --- a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx +++ b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx @@ -8,8 +8,7 @@ * - 编辑元信息(名称、描述、性别、风格标签) * - 删除素材 */ -import React, { useState, useRef, useCallback, useEffect, useMemo } from "react" -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import React from "react" import { AudioOutlined, SearchOutlined, @@ -24,28 +23,12 @@ import { LoadingOutlined, } from "@ant-design/icons" import { Button, Input, Select, Modal } from "@/components/ui" -import { message, Popover, Popconfirm } from "antd" +import { Popover, Popconfirm } from "antd" import PageHead from "@/components/layout/PageHead" -import { - getAssetsByKind, - createAsset, - updateAsset, - deleteAsset, - uploadAssetDirect, - getAssetLibraries, - createAssetLibrary, -} 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 { getAudioDuration } from "./utils/audio" +import { useVoiceMaterials } from "./hooks/useVoiceMaterials" +import { useAudioPlayer } from "./hooks/useAudioPlayer" +import { useBatchOperations } from "./hooks/useBatchOperations" +import { useTtsSynthesize } from "./hooks/useTtsSynthesize" import MaterialForm from "./components/MaterialForm" import VoiceMaterialCard from "./components/VoiceMaterialCard" import VoiceMaterialRow from "./components/VoiceMaterialRow" @@ -56,384 +39,90 @@ import "./voice-materials.css" * ============================================================ */ const VoiceMaterialLibrary: React.FC = () => { - const queryClient = useQueryClient() + // 数据 & 筛选 & 增删改 + const { + tags, + tagMap, + filtered, + tagCountMap, + isLoading, + viewMode, + searchText, + filterGender, + filterTagId, + uploadProgress, + isUploading, + isEditing, + uploadOpen, + editingMaterial, + setViewMode, + setSearchText, + setFilterGender, + setFilterTagId, + setUploadOpen, + setEditingMaterial, + handleCreateTag, + handleUpload, + handleEdit, + handleDelete, + } = useVoiceMaterials() - // ── 获取 voice 类型素材库(用于上传) ────────────────────── - const { data: libraries = [] } = useQuery({ - queryKey: ["asset-libraries"], - queryFn: getAssetLibraries, - staleTime: 60_000, + // 音频播放控制 + const { + playingId, + currentTime, + volume, + handlePlay, + handlePause, + handleSeek, + handleVolumeChange, + toggleMute, + stopPlayback, + } = useAudioPlayer() + + // 批量操作 + const { + selectedIds, + batchMode, + allSelected, + batchCustomTag, + setBatchCustomTag, + handleToggleSelect, + handleSelectAll, + handleBatchDelete, + handleBatchTag, + handleBatchCustomTag, + } = useBatchOperations({ + filtered, + tagMap, + tags, + playingId, + stopPlayback, }) - const voiceLibrary = useMemo(() => libraries.find((lib) => lib.kind === "voice"), [libraries]) + // TTS 合成 + const { + ttsOpen, + ttsText, + ttsVoiceId, + ttsSpeed, + ttsStatus, + ttsAudioUrl, + ttsError, + presetVoices, + setTtsOpen, + setTtsText, + setTtsVoiceId, + setTtsSpeed, + handleTtsSynthesize, + handleTtsSave, + handleTtsClose, + } = useTtsSynthesize() - // 自动创建 voice 素材库(如果不存在) - const createLibMutation = useMutation({ - mutationFn: () => createAssetLibrary({ name: "配音库", kind: "voice" }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) - }, - }) + /* ── 删除确认 ────────────────────────────────────────── */ - // 页面加载时,如果没有 voice 库则创建 - useEffect(() => { - if (libraries.length > 0 && !voiceLibrary && !createLibMutation.isPending) { - createLibMutation.mutate() - } - }, [libraries, voiceLibrary, createLibMutation]) - - // ── 获取标签列表 ─────────────────────────────────────────── - const { data: tags = [] } = useQuery({ - queryKey: ["tags"], - queryFn: getTags, - staleTime: 60_000, - }) - - /** 标签 ID → TagItem 映射(用于卡片/行渲染) */ - const tagMap = useMemo(() => { - const m = new Map() - tags.forEach((t) => m.set(t.id, t)) - return m - }, [tags]) - - /** 创建标签 mutation(供 TagSelector 调用) */ - const createTagMutation = useMutation({ - mutationFn: (name: string) => createTag(name), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["tags"] }) - }, - }) - - /** 创建标签并返回 TagItem(供 TagSelector 使用) */ - const handleCreateTag = useCallback( - async (name: string): Promise => { - return createTagMutation.mutateAsync(name) - }, - [createTagMutation], - ) - - // ── 视图状态 ────────────────────────────────────────────── - const [viewMode, setViewMode] = useState("card") - const [searchText, setSearchText] = useState("") - const [filterGender, setFilterGender] = useState("all") - const [filterTagId, setFilterTagId] = useState("all") - - // ── 获取配音素材列表(筛选参数透传后端) ───────────────── - const filterKeyword = searchText.trim() || undefined - const filterGenderParam = filterGender !== "all" ? filterGender : undefined - const filterTagIdsParam = filterTagId !== "all" ? [filterTagId] : undefined - - const { data: assets = [], isLoading } = useQuery({ - queryKey: [ - "assets", - "voice", - { - keyword: filterKeyword, - gender: filterGenderParam, - tag_ids: filterTagIdsParam, - }, - ], - queryFn: () => - getAssetsByKind("voice", { - keyword: filterKeyword, - gender: filterGenderParam, - tag_ids: filterTagIdsParam, - }), - staleTime: 30_000, - }) - - const materials: VoiceMaterial[] = useMemo(() => assets.map(mapAssetToMaterial), [assets]) - - // ── 获取预设音色列表(AI 配音用) ───────────────────────── - const { data: presetVoicesData } = useQuery({ - queryKey: ["preset-voices"], - queryFn: fetchPresetVoices, - staleTime: 60_000, - }) - const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? [] - - // ── 上传 mutation ───────────────────────────────────────── - const uploadMutation = useMutation({ - mutationFn: async (data: { - file: File - name: string - gender: VoiceGender - description: string - tagIds: string[] - }) => { - setUploadProgress(0) - try { - // 1. 获取或等待 voice library - let lib = voiceLibrary - if (!lib) { - if (createLibMutation.isPending) { - await createLibMutation.mutateAsync() - } - const libs = await queryClient.fetchQuery({ - queryKey: ["asset-libraries"], - queryFn: getAssetLibraries, - }) - lib = libs.find((l) => l.kind === "voice") - if (!lib) throw new Error("无法创建配音库") - } - - // 2. 上传文件(带进度) - const { storage_key } = await uploadAssetDirect({ - file: data.file, - library_id: lib.id, - onProgress: (p) => setUploadProgress(p), - }) - - // 3. 获取音频时长 - const duration = await getAudioDuration(data.file) - - // 4. 创建素材记录 - const asset = await createAsset({ - library_id: lib.id, - name: data.name, - storage_key, - mime_type: data.file.type || "audio/mpeg", - metadata: buildMetadata({ - gender: data.gender, - description: data.description, - duration, - }), - }) - - // 5. 打标签(标签走独立 API) - if (data.tagIds.length > 0) { - await tagAsset(asset.id, data.tagIds) - } - } finally { - setUploadProgress(null) - } - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) - queryClient.invalidateQueries({ queryKey: ["tags"] }) - }, - onError: (err: Error) => { - message.error(err.message || "上传失败,请重试") - }, - }) - - // ── 编辑 mutation ───────────────────────────────────────── - const editMutation = useMutation({ - mutationFn: async (data: { - id: string - name: string - gender: VoiceGender - description: string - tagIds: string[] - }) => { - // 1. 更新基础信息 - await updateAsset(data.id, { - name: data.name, - metadata: buildMetadata({ - gender: data.gender, - description: data.description, - }), - }) - - // 2. 对比标签差异,调用 tag/untag API - const currentAsset = materials.find((m) => m.id === data.id) - const oldTagIds = currentAsset?.tagIds ?? [] - const newTagIds = data.tagIds - - const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id)) - const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id)) - - if (toAdd.length > 0) { - await tagAsset(data.id, toAdd) - } - for (const tagId of toRemove) { - await untagAsset(data.id, tagId) - } - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) - queryClient.invalidateQueries({ queryKey: ["tags"] }) - }, - }) - - // ── 删除 mutation ───────────────────────────────────────── - const deleteMutation = useMutation({ - mutationFn: (assetId: string) => deleteAsset(assetId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) - }, - }) - - // ── 播放状态 ────────────────────────────────────────────── - const [playingId, setPlayingId] = useState(null) - const [currentTime, setCurrentTime] = useState(0) - const audioRef = useRef(null) - - // ── 弹窗状态 ────────────────────────────────────────────── - const [uploadOpen, setUploadOpen] = useState(false) - const [editingMaterial, setEditingMaterial] = useState(null) - - // ── 批量操作 / 上传进度 / 音量 ──────────────────────────── - const [selectedIds, setSelectedIds] = useState>(new Set()) - const [uploadProgress, setUploadProgress] = useState(null) - const [volume, setVolume] = useState(0.7) - const [pausedMaterial, setPausedMaterial] = useState(null) - const [batchCustomTag, setBatchCustomTag] = useState("") - - // ── AI 配音(TTS 合成)状态 ──────────────────────────────── - const [ttsOpen, setTtsOpen] = useState(false) - const [ttsText, setTtsText] = useState("") - const [ttsVoiceId, setTtsVoiceId] = useState("") - const [ttsSpeed, setTtsSpeed] = useState(1.0) - const [ttsJobId, setTtsJobId] = useState(null) - const [ttsStatus, setTtsStatus] = useState<"idle" | "synthesizing" | "done" | "error">("idle") - const [ttsAudioUrl, setTtsAudioUrl] = useState(null) - const [ttsError, setTtsError] = useState(null) - const ttsTimerRef = useRef | null>(null) - - // ── 播放控制 ────────────────────────────────────────────── - const stopPlayback = useCallback(() => { - if (audioRef.current) { - audioRef.current.pause() - audioRef.current = null - } - setPlayingId(null) - setCurrentTime(0) - setPausedMaterial(null) - }, []) - - const startPlayback = useCallback( - (material: VoiceMaterial) => { - if (!material.fileUrl) return - stopPlayback() - - const audio = new Audio(material.fileUrl) - audio.volume = volume - audioRef.current = audio - - audio.addEventListener("timeupdate", () => { - setCurrentTime(audio.currentTime) - }) - - audio.addEventListener("ended", () => { - setPlayingId(null) - setCurrentTime(0) - audioRef.current = null - setPausedMaterial(null) - }) - - audio.play().catch(() => { - audioRef.current = null - setPlayingId(null) - }) - - setPlayingId(material.id) - setCurrentTime(0) - setPausedMaterial(null) - }, - [stopPlayback, volume], - ) - - const handlePlay = useCallback( - (material: VoiceMaterial) => { - if (playingId === material.id) return - // 恢复暂停 - if (pausedMaterial?.id === material.id && audioRef.current && audioRef.current.paused) { - audioRef.current.play().catch(() => {}) - setPlayingId(material.id) - setPausedMaterial(null) - return - } - startPlayback(material) - }, - [playingId, pausedMaterial, startPlayback], - ) - - const handlePause = useCallback((material?: VoiceMaterial) => { - if (audioRef.current) { - audioRef.current.pause() - } - setPlayingId(null) - if (material) setPausedMaterial(material) - }, []) - - const handleSeek = useCallback( - (material: VoiceMaterial, time: number) => { - if (audioRef.current) { - audioRef.current.currentTime = time - setCurrentTime(time) - } else { - startPlayback(material) - setTimeout(() => { - if (audioRef.current) { - audioRef.current.currentTime = time - } - }, 100) - } - }, - [startPlayback], - ) - - const handleVolumeChange = useCallback((e: React.ChangeEvent) => { - const v = parseFloat(e.target.value) - setVolume(v) - if (audioRef.current) audioRef.current.volume = v - }, []) - - const toggleMute = useCallback(() => { - if (volume > 0) { - setVolume(0) - if (audioRef.current) audioRef.current.volume = 0 - } else { - setVolume(0.7) - if (audioRef.current) audioRef.current.volume = 0.7 - } - }, [volume]) - - // 组件卸载时清理 audio - useEffect(() => { - return () => { - if (audioRef.current) { - audioRef.current.pause() - audioRef.current = null - } - } - }, []) - - /* ── 数据操作 ──────────────────────────────────────────── */ - - const handleUpload = (data: Omit & { file?: File }) => { - if (!data.file) return - uploadMutation.mutate( - { - file: data.file, - name: data.name, - gender: data.gender, - description: data.description, - tagIds: data.tagIds, - }, - { - onSuccess: () => { - setUploadOpen(false) - }, - }, - ) - } - - const handleEdit = (data: Omit & { file?: File }) => { - if (!editingMaterial) return - editMutation.mutate({ - id: editingMaterial.id, - name: data.name, - gender: data.gender, - description: data.description, - tagIds: data.tagIds, - }) - setEditingMaterial(null) - } - - const handleDelete = (id: string) => { - const material = materials.find((m) => m.id === id) + const confirmDelete = (id: string) => { + const material = filtered.find((m) => m.id === id) if (!material) return Modal.confirm({ title: "确认删除", @@ -443,199 +132,13 @@ const VoiceMaterialLibrary: React.FC = () => { cancelText: "取消", onOk: () => { if (playingId === id) stopPlayback() - deleteMutation.mutate(id) + handleDelete(id) }, }) } - /* ── 筛选 ─────────────────────────────────────────────── */ - - const filtered = React.useMemo(() => { - let list = materials - if (filterGender !== "all") { - list = list.filter((m) => m.gender === filterGender) - } - if (filterTagId !== "all") { - list = list.filter((m) => m.tagIds.includes(filterTagId)) - } - if (searchText.trim()) { - const q = searchText.trim().toLowerCase() - list = list.filter( - (m) => - m.name.toLowerCase().includes(q) || - m.description.toLowerCase().includes(q) || - m.tagIds.some((id) => tagMap.get(id)?.name?.toLowerCase().includes(q)), - ) - } - return list - }, [materials, filterGender, filterTagId, searchText, tagMap]) - - /* ── 标签使用计数(药丸条展示,按 tag ID 统计) ──────────── */ - - const tagCountMap = React.useMemo(() => { - const map: Record = {} - materials.forEach((m) => - m.tagIds.forEach((id) => { - map[id] = (map[id] || 0) + 1 - }), - ) - return map - }, [materials]) - - /* ── 批量操作 ─────────────────────────────────────────── */ - - const batchMode = selectedIds.size > 0 - const allSelected = filtered.length > 0 && filtered.every((m) => selectedIds.has(m.id)) - - const handleToggleSelect = useCallback((id: string) => { - setSelectedIds((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) - }, []) - - const handleSelectAll = useCallback(() => { - if (allSelected) setSelectedIds(new Set()) - else setSelectedIds(new Set(filtered.map((m) => m.id))) - }, [allSelected, filtered]) - - const handleBatchDelete = useCallback(async () => { - const ids = Array.from(selectedIds) - let successCount = 0 - for (const id of ids) { - try { - await deleteAsset(id) - successCount++ - } catch { - /* ignore individual failures */ - } - if (playingId === id) stopPlayback() - } - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) - setSelectedIds(new Set()) - message.success(`已批量删除 ${successCount}/${ids.length} 个素材`) - }, [selectedIds, playingId, stopPlayback, queryClient]) - - const handleBatchTag = useCallback( - async (tagId: string) => { - const ids = Array.from(selectedIds) - let successCount = 0 - for (const id of ids) { - try { - await tagAsset(id, [tagId]) - successCount++ - } catch { - /* ignore individual failures */ - } - } - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) - setSelectedIds(new Set()) - const tagName = tagMap.get(tagId)?.name ?? tagId - if (successCount === 0) { - message.error(`批量打标签失败,请重试`) - } else { - message.success(`已为 ${successCount}/${ids.length} 个素材添加标签「${tagName}」`) - } - }, - [selectedIds, queryClient, tagMap], - ) - - /** 批量打标签 — 自定义输入:按名称查找或创建标签,再批量打标 */ - const handleBatchCustomTag = useCallback( - async (name: string) => { - // 先查找同名标签(不区分大小写) - let existing = tags.find((t) => t.name.toLowerCase() === name.toLowerCase()) - if (!existing) { - try { - existing = await createTagMutation.mutateAsync(name) - } catch { - message.error(`创建标签「${name}」失败`) - return - } - } - await handleBatchTag(existing.id) - }, - [tags, createTagMutation, handleBatchTag], - ) - - // ── TTS 合成处理 ───────────────────────────────────────── - /** 开始 AI 配音合成 */ - const handleTtsSynthesize = useCallback(async () => { - if (!ttsText.trim()) { - message.warning("请输入要合成的文本") - return - } - setTtsError(null) - setTtsStatus("synthesizing") - setTtsAudioUrl(null) - setTtsJobId(null) - - try { - const resp = await synthesizeSpeech({ - text: ttsText.trim(), - voice_id: ttsVoiceId || undefined, - speed: ttsSpeed, - }) - setTtsJobId(resp.job_id) - - // 轮询任务状态 - ttsTimerRef.current = setInterval(async () => { - try { - const job = await getTTSJobStatus(resp.job_id) - if (job.status === "completed") { - clearInterval(ttsTimerRef.current!) - ttsTimerRef.current = null - setTtsStatus("done") - setTtsAudioUrl(job.output_audio_url) - } else if (job.status === "failed") { - clearInterval(ttsTimerRef.current!) - ttsTimerRef.current = null - setTtsStatus("error") - setTtsError(job.error_message || "合成失败") - } - } catch { - clearInterval(ttsTimerRef.current!) - ttsTimerRef.current = null - setTtsStatus("error") - setTtsError("查询合成状态失败") - } - }, 2000) - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : "合成请求失败" - setTtsStatus("error") - setTtsError(msg) - } - }, [ttsText, ttsVoiceId, ttsSpeed]) - - /** 保存 TTS 结果到素材库 */ - const handleTtsSave = useCallback(async () => { - if (!ttsJobId) return - try { - await saveTtsToLibrary(ttsJobId, { - name: ttsText.slice(0, 20) || "AI配音", - }) - message.success("已保存到配音库") - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) - setTtsOpen(false) - } catch { - message.error("保存失败") - } - }, [ttsJobId, ttsText, queryClient]) - - // TTS 定时器清理 - useEffect(() => { - return () => { - if (ttsTimerRef.current) clearInterval(ttsTimerRef.current) - } - }, []) - /* ── 渲染 ─────────────────────────────────────────────── */ - const isUploading = uploadMutation.isPending - const isEditing = editMutation.isPending - const pageActions = (
)} - {/* 内容区 */} + {/* 内容区 — 卡片视图 */} {!isLoading && filtered.length > 0 && viewMode === "card" && (
{filtered.map((m) => ( @@ -827,7 +330,7 @@ const VoiceMaterialLibrary: React.FC = () => { onPause={() => handlePause(m)} onSeek={(t) => handleSeek(m, t)} onEdit={() => setEditingMaterial(m)} - onDelete={() => handleDelete(m.id)} + onDelete={() => confirmDelete(m.id)} onToggleSelect={handleToggleSelect} onVolumeChange={handleVolumeChange} onToggleMute={toggleMute} @@ -836,6 +339,7 @@ const VoiceMaterialLibrary: React.FC = () => {
)} + {/* 内容区 — 列表视图 */} {!isLoading && filtered.length > 0 && viewMode === "list" && (
{/* 列表头 */} @@ -863,13 +367,14 @@ const VoiceMaterialLibrary: React.FC = () => { onPause={() => handlePause(m)} onSeek={(t) => handleSeek(m, t)} onEdit={() => setEditingMaterial(m)} - onDelete={() => handleDelete(m.id)} + onDelete={() => confirmDelete(m.id)} onToggleSelect={handleToggleSelect} /> ))}
)} + {/* 空状态 */} {!isLoading && filtered.length === 0 && (
@@ -940,17 +445,7 @@ const VoiceMaterialLibrary: React.FC = () => { { - setTtsOpen(false) - if (ttsTimerRef.current) { - clearInterval(ttsTimerRef.current) - ttsTimerRef.current = null - } - setTtsStatus("idle") - setTtsAudioUrl(null) - setTtsError(null) - setTtsJobId(null) - }} + onCancel={handleTtsClose} footer={null} width={560} destroyOnClose diff --git a/apps/web/src/pages/voice-materials/hooks/useAudioPlayer.ts b/apps/web/src/pages/voice-materials/hooks/useAudioPlayer.ts new file mode 100644 index 000000000..e2b8cf954 --- /dev/null +++ b/apps/web/src/pages/voice-materials/hooks/useAudioPlayer.ts @@ -0,0 +1,142 @@ +import { useState, useRef, useCallback, useEffect } from "react" +import type { VoiceMaterial } from "../types" + +/** + * 音频播放控制 Hook + * 封装当前播放音频状态、播放/暂停、进度控制、音量控制 + */ +export function useAudioPlayer() { + const [playingId, setPlayingId] = useState(null) + const [currentTime, setCurrentTime] = useState(0) + const [volume, setVolume] = useState(0.7) + const [pausedMaterial, setPausedMaterial] = useState(null) + const audioRef = useRef(null) + + /** 停止当前播放并重置状态 */ + const stopPlayback = useCallback(() => { + if (audioRef.current) { + audioRef.current.pause() + audioRef.current = null + } + setPlayingId(null) + setCurrentTime(0) + setPausedMaterial(null) + }, []) + + /** 从头开始播放指定素材 */ + const startPlayback = useCallback( + (material: VoiceMaterial) => { + if (!material.fileUrl) return + stopPlayback() + + const audio = new Audio(material.fileUrl) + audio.volume = volume + audioRef.current = audio + + audio.addEventListener("timeupdate", () => { + setCurrentTime(audio.currentTime) + }) + + audio.addEventListener("ended", () => { + setPlayingId(null) + setCurrentTime(0) + audioRef.current = null + setPausedMaterial(null) + }) + + audio.play().catch(() => { + audioRef.current = null + setPlayingId(null) + }) + + setPlayingId(material.id) + setCurrentTime(0) + setPausedMaterial(null) + }, + [stopPlayback, volume], + ) + + /** 播放素材(若为暂停状态则恢复) */ + const handlePlay = useCallback( + (material: VoiceMaterial) => { + if (playingId === material.id) return + // 恢复暂停 + if (pausedMaterial?.id === material.id && audioRef.current && audioRef.current.paused) { + audioRef.current.play().catch(() => {}) + setPlayingId(material.id) + setPausedMaterial(null) + return + } + startPlayback(material) + }, + [playingId, pausedMaterial, startPlayback], + ) + + /** 暂停播放 */ + const handlePause = useCallback((material?: VoiceMaterial) => { + if (audioRef.current) { + audioRef.current.pause() + } + setPlayingId(null) + if (material) setPausedMaterial(material) + }, []) + + /** 跳转到指定播放时间 */ + const handleSeek = useCallback( + (material: VoiceMaterial, time: number) => { + if (audioRef.current) { + audioRef.current.currentTime = time + setCurrentTime(time) + } else { + startPlayback(material) + setTimeout(() => { + if (audioRef.current) { + audioRef.current.currentTime = time + } + }, 100) + } + }, + [startPlayback], + ) + + /** 音量调节 */ + const handleVolumeChange = useCallback((e: React.ChangeEvent) => { + const v = parseFloat(e.target.value) + setVolume(v) + if (audioRef.current) audioRef.current.volume = v + }, []) + + /** 静音/取消静音切换 */ + const toggleMute = useCallback(() => { + if (volume > 0) { + setVolume(0) + if (audioRef.current) audioRef.current.volume = 0 + } else { + setVolume(0.7) + if (audioRef.current) audioRef.current.volume = 0.7 + } + }, [volume]) + + // 组件卸载时清理 audio + useEffect(() => { + return () => { + if (audioRef.current) { + audioRef.current.pause() + audioRef.current = null + } + } + }, []) + + return { + playingId, + currentTime, + volume, + pausedMaterial, + stopPlayback, + handlePlay, + handlePause, + handleSeek, + handleVolumeChange, + toggleMute, + } +} diff --git a/apps/web/src/pages/voice-materials/hooks/useBatchOperations.ts b/apps/web/src/pages/voice-materials/hooks/useBatchOperations.ts new file mode 100644 index 000000000..b3f10597c --- /dev/null +++ b/apps/web/src/pages/voice-materials/hooks/useBatchOperations.ts @@ -0,0 +1,132 @@ +import { useState, useCallback, useMemo } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { message } from "antd" +import { deleteAsset, tagAsset } from "@/api/assets" +import { type TagItem, createTag } from "@/api/tags" +import type { VoiceMaterial } from "../types" + +/** + * 批量操作 Hook + * 封装批量选择、批量删除、批量打标签等逻辑 + */ +interface UseBatchOperationsProps { + /** 当前筛选后的素材列表 */ + filtered: VoiceMaterial[] + /** 标签 ID → TagItem 映射 */ + tagMap: Map + /** 所有可用标签 */ + tags: TagItem[] + /** 当前播放中的素材 ID */ + playingId: string | null + /** 停止播放回调 */ + stopPlayback: () => void +} + +export function useBatchOperations({ + filtered, + tagMap, + tags, + playingId, + stopPlayback, +}: UseBatchOperationsProps) { + const queryClient = useQueryClient() + + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [batchCustomTag, setBatchCustomTag] = useState("") + + const batchMode = useMemo(() => selectedIds.size > 0, [selectedIds]) + const allSelected = useMemo( + () => filtered.length > 0 && filtered.every((m) => selectedIds.has(m.id)), + [filtered, selectedIds], + ) + + /** 切换单个素材的选中状态 */ + const handleToggleSelect = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + /** 全选 / 取消全选 */ + const handleSelectAll = useCallback(() => { + if (allSelected) setSelectedIds(new Set()) + else setSelectedIds(new Set(filtered.map((m) => m.id))) + }, [allSelected, filtered]) + + /** 批量删除 */ + const handleBatchDelete = useCallback(async () => { + const ids = Array.from(selectedIds) + let successCount = 0 + for (const id of ids) { + try { + await deleteAsset(id) + successCount++ + } catch { + /* ignore individual failures */ + } + if (playingId === id) stopPlayback() + } + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + setSelectedIds(new Set()) + message.success(`已批量删除 ${successCount}/${ids.length} 个素材`) + }, [selectedIds, playingId, stopPlayback, queryClient]) + + /** 批量打标签(已有标签) */ + const handleBatchTag = useCallback( + async (tagId: string) => { + const ids = Array.from(selectedIds) + let successCount = 0 + for (const id of ids) { + try { + await tagAsset(id, [tagId]) + successCount++ + } catch { + /* ignore individual failures */ + } + } + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + setSelectedIds(new Set()) + const tagName = tagMap.get(tagId)?.name ?? tagId + if (successCount === 0) { + message.error(`批量打标签失败,请重试`) + } else { + message.success(`已为 ${successCount}/${ids.length} 个素材添加标签「${tagName}」`) + } + }, + [selectedIds, queryClient, tagMap], + ) + + /** 批量打标签(自定义输入:按名称查找或创建标签,再批量打标) */ + const handleBatchCustomTag = useCallback( + async (name: string) => { + // 先查找同名标签(不区分大小写) + let existing = tags.find((t) => t.name.toLowerCase() === name.toLowerCase()) + if (!existing) { + try { + existing = await createTag(name) + } catch { + message.error(`创建标签「${name}」失败`) + return + } + } + await handleBatchTag(existing.id) + }, + [tags, handleBatchTag], + ) + + return { + selectedIds, + batchMode, + allSelected, + batchCustomTag, + setBatchCustomTag, + handleToggleSelect, + handleSelectAll, + handleBatchDelete, + handleBatchTag, + handleBatchCustomTag, + } +} diff --git a/apps/web/src/pages/voice-materials/hooks/useTtsSynthesize.ts b/apps/web/src/pages/voice-materials/hooks/useTtsSynthesize.ts new file mode 100644 index 000000000..fa958ba5e --- /dev/null +++ b/apps/web/src/pages/voice-materials/hooks/useTtsSynthesize.ts @@ -0,0 +1,135 @@ +import { useState, useRef, useCallback, useEffect } from "react" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { message } from "antd" +import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts" +import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices" + +/** + * TTS 合成 Hook + * 封装合成弹窗状态、合成请求、轮询、保存到素材库等逻辑 + */ +export type TtsStatus = "idle" | "synthesizing" | "done" | "error" + +export function useTtsSynthesize() { + const queryClient = useQueryClient() + + const [ttsOpen, setTtsOpen] = useState(false) + const [ttsText, setTtsText] = useState("") + const [ttsVoiceId, setTtsVoiceId] = useState("") + const [ttsSpeed, setTtsSpeed] = useState(1.0) + const [ttsJobId, setTtsJobId] = useState(null) + const [ttsStatus, setTtsStatus] = useState("idle") + const [ttsAudioUrl, setTtsAudioUrl] = useState(null) + const [ttsError, setTtsError] = useState(null) + const ttsTimerRef = useRef | null>(null) + + // 预设音色列表 + const { data: presetVoicesData } = useQuery({ + queryKey: ["preset-voices"], + queryFn: fetchPresetVoices, + staleTime: 60_000, + }) + const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? [] + + /** 开始 AI 配音合成 */ + const handleTtsSynthesize = useCallback(async () => { + if (!ttsText.trim()) { + message.warning("请输入要合成的文本") + return + } + setTtsError(null) + setTtsStatus("synthesizing") + setTtsAudioUrl(null) + setTtsJobId(null) + + try { + const resp = await synthesizeSpeech({ + text: ttsText.trim(), + voice_id: ttsVoiceId || undefined, + speed: ttsSpeed, + }) + setTtsJobId(resp.job_id) + + // 轮询任务状态 + ttsTimerRef.current = setInterval(async () => { + try { + const job = await getTTSJobStatus(resp.job_id) + if (job.status === "completed") { + clearInterval(ttsTimerRef.current!) + ttsTimerRef.current = null + setTtsStatus("done") + setTtsAudioUrl(job.output_audio_url) + } else if (job.status === "failed") { + clearInterval(ttsTimerRef.current!) + ttsTimerRef.current = null + setTtsStatus("error") + setTtsError(job.error_message || "合成失败") + } + } catch { + clearInterval(ttsTimerRef.current!) + ttsTimerRef.current = null + setTtsStatus("error") + setTtsError("查询合成状态失败") + } + }, 2000) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "合成请求失败" + setTtsStatus("error") + setTtsError(msg) + } + }, [ttsText, ttsVoiceId, ttsSpeed]) + + /** 保存 TTS 结果到素材库 */ + const handleTtsSave = useCallback(async () => { + if (!ttsJobId) return + try { + await saveTtsToLibrary(ttsJobId, { + name: ttsText.slice(0, 20) || "AI配音", + }) + message.success("已保存到配音库") + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + setTtsOpen(false) + } catch { + message.error("保存失败") + } + }, [ttsJobId, ttsText, queryClient]) + + /** 关闭 TTS 弹窗并清理状态 */ + const handleTtsClose = useCallback(() => { + setTtsOpen(false) + if (ttsTimerRef.current) { + clearInterval(ttsTimerRef.current) + ttsTimerRef.current = null + } + setTtsStatus("idle") + setTtsAudioUrl(null) + setTtsError(null) + setTtsJobId(null) + }, []) + + // 组件卸载时清理定时器 + useEffect(() => { + return () => { + if (ttsTimerRef.current) clearInterval(ttsTimerRef.current) + } + }, []) + + return { + ttsOpen, + ttsText, + ttsVoiceId, + ttsSpeed, + ttsJobId, + ttsStatus, + ttsAudioUrl, + ttsError, + presetVoices, + setTtsOpen, + setTtsText, + setTtsVoiceId, + setTtsSpeed, + handleTtsSynthesize, + handleTtsSave, + handleTtsClose, + } +} diff --git a/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials.ts b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials.ts new file mode 100644 index 000000000..0066b1b86 --- /dev/null +++ b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials.ts @@ -0,0 +1,351 @@ +import { useState, useMemo, useCallback, useEffect } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { message } from "antd" +import { + getAssetsByKind, + createAsset, + updateAsset, + deleteAsset, + uploadAssetDirect, + getAssetLibraries, + createAssetLibrary, +} from "@/api/assets" +import { type TagItem, getTags, createTag, tagAsset, untagAsset } from "@/api/tags" +import { + type VoiceGender, + type ViewMode, + type VoiceMaterial, + mapAssetToMaterial, + buildMetadata, +} from "../types" +import { getAudioDuration } from "../utils/audio" + +/** + * 配音素材数据 Hook + * 封装素材列表查询、筛选状态管理、增删改等数据操作逻辑 + */ +export function useVoiceMaterials() { + const queryClient = useQueryClient() + + // ── 获取 voice 类型素材库(用于上传) ────────────────────── + const { data: libraries = [] } = useQuery({ + queryKey: ["asset-libraries"], + queryFn: getAssetLibraries, + staleTime: 60_000, + }) + + const voiceLibrary = useMemo(() => libraries.find((lib) => lib.kind === "voice"), [libraries]) + + // 自动创建 voice 素材库(如果不存在) + const createLibMutation = useMutation({ + mutationFn: () => createAssetLibrary({ name: "配音库", kind: "voice" }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) + }, + }) + + useEffect(() => { + if (libraries.length > 0 && !voiceLibrary && !createLibMutation.isPending) { + createLibMutation.mutate() + } + }, [libraries, voiceLibrary, createLibMutation]) + + // ── 获取标签列表 ─────────────────────────────────────────── + const { data: tags = [] } = useQuery({ + queryKey: ["tags"], + queryFn: getTags, + staleTime: 60_000, + }) + + /** 标签 ID → TagItem 映射(用于卡片/行渲染) */ + const tagMap = useMemo(() => { + const m = new Map() + tags.forEach((t) => m.set(t.id, t)) + return m + }, [tags]) + + /** 创建标签 mutation(供 TagSelector 调用) */ + const createTagMutation = useMutation({ + mutationFn: (name: string) => createTag(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tags"] }) + }, + }) + + /** 创建标签并返回 TagItem(供 TagSelector 使用) */ + const handleCreateTag = useCallback( + async (name: string): Promise => { + return createTagMutation.mutateAsync(name) + }, + [createTagMutation], + ) + + // ── 视图 & 筛选状态 ──────────────────────────────────────── + const [viewMode, setViewMode] = useState("card") + const [searchText, setSearchText] = useState("") + const [filterGender, setFilterGender] = useState("all") + const [filterTagId, setFilterTagId] = useState("all") + + // ── 获取配音素材列表(筛选参数透传后端) ───────────────── + const filterKeyword = searchText.trim() || undefined + const filterGenderParam = filterGender !== "all" ? filterGender : undefined + const filterTagIdsParam = filterTagId !== "all" ? [filterTagId] : undefined + + const { data: assets = [], isLoading } = useQuery({ + queryKey: [ + "assets", + "voice", + { + keyword: filterKeyword, + gender: filterGenderParam, + tag_ids: filterTagIdsParam, + }, + ], + queryFn: () => + getAssetsByKind("voice", { + keyword: filterKeyword, + gender: filterGenderParam, + tag_ids: filterTagIdsParam, + }), + staleTime: 30_000, + }) + + const materials: VoiceMaterial[] = useMemo(() => assets.map(mapAssetToMaterial), [assets]) + + // ── 弹窗状态 ────────────────────────────────────────────── + const [uploadOpen, setUploadOpen] = useState(false) + const [editingMaterial, setEditingMaterial] = useState(null) + + // ── 上传进度 ────────────────────────────────────────────── + const [uploadProgress, setUploadProgress] = useState(null) + + // ── 上传 mutation ───────────────────────────────────────── + const uploadMutation = useMutation({ + mutationFn: async (data: { + file: File + name: string + gender: VoiceGender + description: string + tagIds: string[] + }) => { + setUploadProgress(0) + try { + // 1. 获取或等待 voice library + let lib = voiceLibrary + if (!lib) { + if (createLibMutation.isPending) { + await createLibMutation.mutateAsync() + } + const libs = await queryClient.fetchQuery({ + queryKey: ["asset-libraries"], + queryFn: getAssetLibraries, + }) + lib = libs.find((l) => l.kind === "voice") + if (!lib) throw new Error("无法创建配音库") + } + + // 2. 上传文件(带进度) + const { storage_key } = await uploadAssetDirect({ + file: data.file, + library_id: lib.id, + onProgress: (p) => setUploadProgress(p), + }) + + // 3. 获取音频时长 + const duration = await getAudioDuration(data.file) + + // 4. 创建素材记录 + const asset = await createAsset({ + library_id: lib.id, + name: data.name, + storage_key, + mime_type: data.file.type || "audio/mpeg", + metadata: buildMetadata({ + gender: data.gender, + description: data.description, + duration, + }), + }) + + // 5. 打标签(标签走独立 API) + if (data.tagIds.length > 0) { + await tagAsset(asset.id, data.tagIds) + } + } finally { + setUploadProgress(null) + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + queryClient.invalidateQueries({ queryKey: ["tags"] }) + }, + onError: (err: Error) => { + message.error(err.message || "上传失败,请重试") + }, + }) + + // ── 编辑 mutation ───────────────────────────────────────── + const editMutation = useMutation({ + mutationFn: async (data: { + id: string + name: string + gender: VoiceGender + description: string + tagIds: string[] + }) => { + // 1. 更新基础信息 + await updateAsset(data.id, { + name: data.name, + metadata: buildMetadata({ + gender: data.gender, + description: data.description, + }), + }) + + // 2. 对比标签差异,调用 tag/untag API + const currentAsset = materials.find((m) => m.id === data.id) + const oldTagIds = currentAsset?.tagIds ?? [] + const newTagIds = data.tagIds + + const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id)) + const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id)) + + if (toAdd.length > 0) { + await tagAsset(data.id, toAdd) + } + for (const tagId of toRemove) { + await untagAsset(data.id, tagId) + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + queryClient.invalidateQueries({ queryKey: ["tags"] }) + }, + }) + + // ── 删除 mutation ───────────────────────────────────────── + const deleteMutation = useMutation({ + mutationFn: (assetId: string) => deleteAsset(assetId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + }, + }) + + /* ── 前端二次筛选(与后端筛选同时存在) ──────────────────── */ + + const filtered = useMemo(() => { + let list = materials + if (filterGender !== "all") { + list = list.filter((m) => m.gender === filterGender) + } + if (filterTagId !== "all") { + list = list.filter((m) => m.tagIds.includes(filterTagId)) + } + if (searchText.trim()) { + const q = searchText.trim().toLowerCase() + list = list.filter( + (m) => + m.name.toLowerCase().includes(q) || + m.description.toLowerCase().includes(q) || + m.tagIds.some((id) => tagMap.get(id)?.name?.toLowerCase().includes(q)), + ) + } + return list + }, [materials, filterGender, filterTagId, searchText, tagMap]) + + /* ── 标签使用计数(药丸条展示,按 tag ID 统计) ──────────── */ + + const tagCountMap = useMemo(() => { + const map: Record = {} + materials.forEach((m) => + m.tagIds.forEach((id) => { + map[id] = (map[id] || 0) + 1 + }), + ) + return map + }, [materials]) + + /* ── 数据操作 handlers ──────────────────────────────────── */ + + const handleUpload = useCallback( + (data: Omit & { file?: File }) => { + if (!data.file) return + uploadMutation.mutate( + { + file: data.file, + name: data.name, + gender: data.gender, + description: data.description, + tagIds: data.tagIds, + }, + { + onSuccess: () => { + setUploadOpen(false) + }, + }, + ) + }, + [uploadMutation], + ) + + const handleEdit = useCallback( + (data: Omit & { file?: File }) => { + if (!editingMaterial) return + editMutation.mutate({ + id: editingMaterial.id, + name: data.name, + gender: data.gender, + description: data.description, + tagIds: data.tagIds, + }) + setEditingMaterial(null) + }, + [editingMaterial, editMutation], + ) + + const handleDelete = useCallback( + (id: string, onBeforeDelete?: () => void) => { + const material = materials.find((m) => m.id === id) + if (!material) return + if (onBeforeDelete) onBeforeDelete() + deleteMutation.mutate(id) + }, + [materials, deleteMutation], + ) + + return { + // 数据 + libraries, + voiceLibrary, + tags, + tagMap, + materials, + filtered, + tagCountMap, + isLoading, + // 视图 & 筛选状态 + viewMode, + searchText, + filterGender, + filterTagId, + // 上传 & 编辑状态 + uploadProgress, + isUploading: uploadMutation.isPending, + isEditing: editMutation.isPending, + // 弹窗状态 + uploadOpen, + editingMaterial, + // 视图控制 + setViewMode, + setSearchText, + setFilterGender, + setFilterTagId, + setUploadOpen, + setEditingMaterial, + // 操作 + handleCreateTag, + handleUpload, + handleEdit, + handleDelete, + } +} diff --git a/apps/web/src/test/pages/voice-materials/hooks/useAudioPlayer.test.ts b/apps/web/src/test/pages/voice-materials/hooks/useAudioPlayer.test.ts new file mode 100644 index 000000000..bb3dfce53 --- /dev/null +++ b/apps/web/src/test/pages/voice-materials/hooks/useAudioPlayer.test.ts @@ -0,0 +1,148 @@ +/** + * useAudioPlayer hook 测试 + */ +import { describe, it, expect, beforeEach, vi } from "vitest" +import { renderHook, act } from "@testing-library/react" +import { useAudioPlayer } from "@/pages/voice-materials/hooks/useAudioPlayer" +import type { VoiceMaterial } from "@/pages/voice-materials/types" + +// Mock Audio constructor +const mockAudioPlay = vi.fn() +const mockAudioPause = vi.fn() +const mockAddEventListener = vi.fn() + +beforeEach(() => { + vi.clearAllMocks() + mockAudioPlay.mockReset() + mockAudioPause.mockReset() + mockAddEventListener.mockReset() + + // Mock HTMLAudioElement + global.Audio = vi.fn().mockImplementation(() => ({ + play: mockAudioPlay.mockResolvedValue(undefined), + pause: mockAudioPause, + addEventListener: mockAddEventListener, + currentTime: 0, + volume: 0.7, + paused: true, + })) as unknown as typeof Audio +}) + +const mockMaterial: VoiceMaterial = { + id: "test-1", + name: "测试素材", + description: "测试描述", + gender: "male", + tagIds: ["tag-1"], + fileName: "test.mp3", + fileSize: 1024, + duration: 30, + mimeType: "audio/mpeg", + createdAt: "2024-01-01T00:00:00Z", + fileUrl: "https://example.com/test.mp3", +} + +describe("useAudioPlayer", () => { + it("应该使用初始状态初始化", () => { + const { result } = renderHook(() => useAudioPlayer()) + + expect(result.current.playingId).toBeNull() + expect(result.current.currentTime).toBe(0) + expect(result.current.volume).toBe(0.7) + expect(result.current.pausedMaterial).toBeNull() + }) + + it("stopPlayback 应该重置播放状态", () => { + const { result } = renderHook(() => useAudioPlayer()) + + act(() => { + result.current.stopPlayback() + }) + + expect(result.current.playingId).toBeNull() + expect(result.current.currentTime).toBe(0) + expect(result.current.pausedMaterial).toBeNull() + }) + + it("handlePause 应该暂停播放并设置 pausedMaterial", () => { + const { result } = renderHook(() => useAudioPlayer()) + + act(() => { + result.current.handlePause(mockMaterial) + }) + + expect(result.current.playingId).toBeNull() + expect(result.current.pausedMaterial).toEqual(mockMaterial) + }) + + it("handlePause 不传参数时不设置 pausedMaterial", () => { + const { result } = renderHook(() => useAudioPlayer()) + + act(() => { + result.current.handlePause() + }) + + expect(result.current.playingId).toBeNull() + expect(result.current.pausedMaterial).toBeNull() + }) + + it("toggleMute 应该切换静音状态", () => { + const { result } = renderHook(() => useAudioPlayer()) + + // 默认音量 0.7,静音后应为 0 + act(() => { + result.current.toggleMute() + }) + expect(result.current.volume).toBe(0) + + // 再次切换,恢复到 0.7 + act(() => { + result.current.toggleMute() + }) + expect(result.current.volume).toBe(0.7) + }) + + it("handlePlay 应该开始播放素材", () => { + const { result } = renderHook(() => useAudioPlayer()) + + act(() => { + result.current.handlePlay(mockMaterial) + }) + + expect(result.current.playingId).toBe("test-1") + expect(result.current.currentTime).toBe(0) + expect(global.Audio).toHaveBeenCalledWith("https://example.com/test.mp3") + expect(mockAudioPlay).toHaveBeenCalled() + }) + + it("handlePlay 对同一个素材不应重复播放", () => { + const { result } = renderHook(() => useAudioPlayer()) + + act(() => { + result.current.handlePlay(mockMaterial) + }) + + const playCallCount = mockAudioPlay.mock.calls.length + + act(() => { + result.current.handlePlay(mockMaterial) + }) + + // 不应该再次调用 play + expect(mockAudioPlay.mock.calls.length).toBe(playCallCount) + }) + + it("返回值应该包含所有必要的方法和状态", () => { + const { result } = renderHook(() => useAudioPlayer()) + + expect(typeof result.current.handlePlay).toBe("function") + expect(typeof result.current.handlePause).toBe("function") + expect(typeof result.current.handleSeek).toBe("function") + expect(typeof result.current.handleVolumeChange).toBe("function") + expect(typeof result.current.toggleMute).toBe("function") + expect(typeof result.current.stopPlayback).toBe("function") + expect(typeof result.current.playingId).toBe("object") // string | null + expect(typeof result.current.currentTime).toBe("number") + expect(typeof result.current.volume).toBe("number") + }) +}) -- 2.54.0 From 74243f009ed41bec11ee63a3977330e267946d86 Mon Sep 17 00:00:00 2001 From: saas-frontend-bot Date: Sat, 25 Jul 2026 03:46:59 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20useBatchOperati?= =?UTF-8?q?ons=20=E4=B8=AD=20tagAsset=20=E5=AF=BC=E5=85=A5=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E9=94=99=E8=AF=AF=EF=BC=88=E5=BA=94=E4=BB=8E@/api/tag?= =?UTF-8?q?s=E5=AF=BC=E5=85=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/src/pages/voice-materials/hooks/useBatchOperations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/voice-materials/hooks/useBatchOperations.ts b/apps/web/src/pages/voice-materials/hooks/useBatchOperations.ts index b3f10597c..675bf11fd 100644 --- a/apps/web/src/pages/voice-materials/hooks/useBatchOperations.ts +++ b/apps/web/src/pages/voice-materials/hooks/useBatchOperations.ts @@ -1,8 +1,8 @@ import { useState, useCallback, useMemo } from "react" import { useQueryClient } from "@tanstack/react-query" import { message } from "antd" -import { deleteAsset, tagAsset } from "@/api/assets" -import { type TagItem, createTag } from "@/api/tags" +import { deleteAsset } from "@/api/assets" +import { type TagItem, createTag, tagAsset } from "@/api/tags" import type { VoiceMaterial } from "../types" /** -- 2.54.0