refactor(voice-materials): phase 3 extract hooks #866
@@ -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<string, TagItem>()
|
||||
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<TagItem> => {
|
||||
return createTagMutation.mutateAsync(name)
|
||||
},
|
||||
[createTagMutation],
|
||||
)
|
||||
|
||||
// ── 视图状态 ──────────────────────────────────────────────
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("card")
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterGender, setFilterGender] = useState<string>("all")
|
||||
const [filterTagId, setFilterTagId] = useState<string>("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<string | null>(null)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
// ── 弹窗状态 ──────────────────────────────────────────────
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
const [editingMaterial, setEditingMaterial] = useState<VoiceMaterial | null>(null)
|
||||
|
||||
// ── 批量操作 / 上传进度 / 音量 ────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||
const [volume, setVolume] = useState(0.7)
|
||||
const [pausedMaterial, setPausedMaterial] = useState<VoiceMaterial | null>(null)
|
||||
const [batchCustomTag, setBatchCustomTag] = useState("")
|
||||
|
||||
// ── AI 配音(TTS 合成)状态 ────────────────────────────────
|
||||
const [ttsOpen, setTtsOpen] = useState(false)
|
||||
const [ttsText, setTtsText] = useState("")
|
||||
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
|
||||
const [ttsSpeed, setTtsSpeed] = useState(1.0)
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
|
||||
const [ttsStatus, setTtsStatus] = useState<"idle" | "synthesizing" | "done" | "error">("idle")
|
||||
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null)
|
||||
const [ttsError, setTtsError] = useState<string | null>(null)
|
||||
const ttsTimerRef = useRef<ReturnType<typeof setInterval> | 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<HTMLInputElement>) => {
|
||||
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<VoiceMaterial, "id" | "createdAt"> & { 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<VoiceMaterial, "id" | "createdAt"> & { 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<string, number> = {}
|
||||
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 = (
|
||||
<div className="vmat-page-actions">
|
||||
<Button buttonSize="sm" icon={<RobotOutlined />} onClick={() => setTtsOpen(true)}>
|
||||
@@ -810,7 +313,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容区 */}
|
||||
{/* 内容区 — 卡片视图 */}
|
||||
{!isLoading && filtered.length > 0 && viewMode === "card" && (
|
||||
<div className="vmat-grid">
|
||||
{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 = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容区 — 列表视图 */}
|
||||
{!isLoading && filtered.length > 0 && viewMode === "list" && (
|
||||
<div className={`vmat-list${batchMode ? " batch-mode" : ""}`}>
|
||||
{/* 列表头 */}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="vmat-empty">
|
||||
<div className="vmat-empty-icon">
|
||||
@@ -940,17 +445,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
<Modal
|
||||
title="AI 配音"
|
||||
open={ttsOpen}
|
||||
onCancel={() => {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import type { VoiceMaterial } from "../types"
|
||||
|
||||
/**
|
||||
* 音频播放控制 Hook
|
||||
* 封装当前播放音频状态、播放/暂停、进度控制、音量控制
|
||||
*/
|
||||
export function useAudioPlayer() {
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [volume, setVolume] = useState(0.7)
|
||||
const [pausedMaterial, setPausedMaterial] = useState<VoiceMaterial | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | 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<HTMLInputElement>) => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { deleteAsset } from "@/api/assets"
|
||||
import { type TagItem, createTag, tagAsset } from "@/api/tags"
|
||||
import type { VoiceMaterial } from "../types"
|
||||
|
||||
/**
|
||||
* 批量操作 Hook
|
||||
* 封装批量选择、批量删除、批量打标签等逻辑
|
||||
*/
|
||||
interface UseBatchOperationsProps {
|
||||
/** 当前筛选后的素材列表 */
|
||||
filtered: VoiceMaterial[]
|
||||
/** 标签 ID → TagItem 映射 */
|
||||
tagMap: Map<string, TagItem>
|
||||
/** 所有可用标签 */
|
||||
tags: TagItem[]
|
||||
/** 当前播放中的素材 ID */
|
||||
playingId: string | null
|
||||
/** 停止播放回调 */
|
||||
stopPlayback: () => void
|
||||
}
|
||||
|
||||
export function useBatchOperations({
|
||||
filtered,
|
||||
tagMap,
|
||||
tags,
|
||||
playingId,
|
||||
stopPlayback,
|
||||
}: UseBatchOperationsProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(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,
|
||||
}
|
||||
}
|
||||
@@ -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<string>("")
|
||||
const [ttsSpeed, setTtsSpeed] = useState(1.0)
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
|
||||
const [ttsStatus, setTtsStatus] = useState<TtsStatus>("idle")
|
||||
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null)
|
||||
const [ttsError, setTtsError] = useState<string | null>(null)
|
||||
const ttsTimerRef = useRef<ReturnType<typeof setInterval> | 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,
|
||||
}
|
||||
}
|
||||
@@ -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<string, TagItem>()
|
||||
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<TagItem> => {
|
||||
return createTagMutation.mutateAsync(name)
|
||||
},
|
||||
[createTagMutation],
|
||||
)
|
||||
|
||||
// ── 视图 & 筛选状态 ────────────────────────────────────────
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("card")
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterGender, setFilterGender] = useState<string>("all")
|
||||
const [filterTagId, setFilterTagId] = useState<string>("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<VoiceMaterial | null>(null)
|
||||
|
||||
// ── 上传进度 ──────────────────────────────────────────────
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(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<string, number> = {}
|
||||
materials.forEach((m) =>
|
||||
m.tagIds.forEach((id) => {
|
||||
map[id] = (map[id] || 0) + 1
|
||||
}),
|
||||
)
|
||||
return map
|
||||
}, [materials])
|
||||
|
||||
/* ── 数据操作 handlers ──────────────────────────────────── */
|
||||
|
||||
const handleUpload = useCallback(
|
||||
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { 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<VoiceMaterial, "id" | "createdAt"> & { 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,
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user