Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78b0da47a5 | |||
| 23a61c7048 | |||
| efbfeb91b2 | |||
| 137480d0c6 | |||
| 65a2081aa2 | |||
| 7b23e35d96 |
Executable → Regular
+132
-410
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* 配音库页面 — V21 设计系统
|
||||
*
|
||||
* 任务 3.11:删除 Mock 数据,使用 useQuery 对接后端真实 API
|
||||
*
|
||||
* Tab 1:预置音色 — fetchPresetVoices()
|
||||
* Tab 2:我的克隆 — getVoiceClonesWithTotal()
|
||||
* 统计:fetchVoices({ limit: 1 }) 获取 preset_count / clone_count
|
||||
* Phase 3: 逻辑抽离为 Hooks
|
||||
* - useVoicesData: 三 Tab 数据查询 + 筛选
|
||||
* - useAudioPlayer: 播放控制
|
||||
* - useCloneOperations: 克隆音色删除/重试/详情
|
||||
* - useTtsSynthesize: AI 配音合成
|
||||
* - useVoiceUpload: 上传音频
|
||||
*/
|
||||
import React, { useMemo, useState, useRef, useEffect, useCallback } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import React, { useCallback, useState } from "react"
|
||||
import {
|
||||
SoundOutlined,
|
||||
PlusOutlined,
|
||||
@@ -17,43 +17,23 @@ import {
|
||||
AudioOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { message } from "antd"
|
||||
import { Button } from "@/components/ui"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { fetchPresetVoices, fetchVoices } from "@/api/voices"
|
||||
import {
|
||||
getVoiceClonesWithTotal,
|
||||
deleteVoiceClone,
|
||||
retryVoiceClone,
|
||||
toVoiceClone,
|
||||
type VoiceClone,
|
||||
} from "@/api/voice-clone"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import {
|
||||
getAssetsByKind,
|
||||
type AssetItem,
|
||||
uploadAssetDirect,
|
||||
getAssetLibraries,
|
||||
createAsset,
|
||||
} from "@/api/assets"
|
||||
import {
|
||||
type VoiceGender,
|
||||
type TabKey,
|
||||
type ClonedVoiceDisplay,
|
||||
mapPresetToDisplay,
|
||||
mapCloneToDisplay,
|
||||
buildVoiceMetadata,
|
||||
} from "@/pages/voices/types"
|
||||
import { type AssetItem } from "@/api/assets"
|
||||
import { genderLabel, languageLabel } from "@/pages/voices/utils/format"
|
||||
import { getAudioDuration } from "@/pages/voices/utils/audio"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import VoiceCard from "@/pages/voices/components/VoiceCard"
|
||||
import CloneVoiceCard from "@/pages/voices/components/CloneVoiceCard"
|
||||
import CloneDetailModal from "@/pages/voices/components/CloneDetailModal"
|
||||
import CloneCardSkeleton from "@/pages/voices/components/CloneCardSkeleton"
|
||||
import UploadVoiceModal from "@/pages/voices/components/UploadVoiceModal"
|
||||
import TtsModal, { type TtsStatus } from "@/pages/voices/components/TtsModal"
|
||||
import TtsModal from "@/pages/voices/components/TtsModal"
|
||||
import VoiceFilterBar from "@/pages/voices/components/VoiceFilterBar"
|
||||
import { useVoicesData } from "@/pages/voices/hooks/useVoicesData"
|
||||
import { useAudioPlayer } from "@/pages/voices/hooks/useAudioPlayer"
|
||||
import { useCloneOperations } from "@/pages/voices/hooks/useCloneOperations"
|
||||
import { useTtsSynthesize } from "@/pages/voices/hooks/useTtsSynthesize"
|
||||
import { useVoiceUpload } from "@/pages/voices/hooks/useVoiceUpload"
|
||||
import "./voices.css"
|
||||
|
||||
interface Toast {
|
||||
@@ -65,38 +45,8 @@ interface Toast {
|
||||
let toastIdSeq = 0
|
||||
|
||||
const VoiceLibrary: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("preset")
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterGender, setFilterGender] = useState<string>("all")
|
||||
const [filterLang, setFilterLang] = useState<string>("all")
|
||||
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const intervalRef = useRef<number | null>(null)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const [detailVoice, setDetailVoice] = useState<ClonedVoiceDisplay | null>(null)
|
||||
// ── Toast ─────────────────────────────────────────────
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
|
||||
/* ── 上传音频弹窗状态 ── */
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null)
|
||||
const [uploadName, setUploadName] = useState("")
|
||||
const [uploadGender, setUploadGender] = useState<VoiceGender>("female")
|
||||
const [uploadDesc, setUploadDesc] = useState("")
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||
|
||||
/* ── AI 配音弹窗状态 ── */
|
||||
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 showToast = useCallback((message: string, type: Toast["type"]) => {
|
||||
const id = ++toastIdSeq
|
||||
@@ -106,301 +56,113 @@ const VoiceLibrary: React.FC = () => {
|
||||
}, 3000)
|
||||
}, [])
|
||||
|
||||
/** 删除克隆音色 */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteVoiceClone,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-clones"] })
|
||||
showToast("音色已删除", "success")
|
||||
},
|
||||
onError: () => {
|
||||
showToast("删除失败", "error")
|
||||
},
|
||||
})
|
||||
// ── 数据 & 筛选 ───────────────────────────────────────
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterGender,
|
||||
setFilterGender,
|
||||
filterLang,
|
||||
setFilterLang,
|
||||
presetLoading,
|
||||
cloneLoading,
|
||||
materialLoading,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
materials,
|
||||
filteredPreset,
|
||||
presetCount,
|
||||
cloneCount,
|
||||
materialCount,
|
||||
} = useVoicesData()
|
||||
|
||||
/** 重试克隆 */
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryVoiceClone,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-clones"] })
|
||||
showToast("已重新提交克隆", "success")
|
||||
},
|
||||
onError: () => {
|
||||
showToast("重试失败", "error")
|
||||
},
|
||||
})
|
||||
// ── 播放控制 ──────────────────────────────────────────
|
||||
const {
|
||||
playingId,
|
||||
currentTime,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleSeek,
|
||||
handleTogglePlay,
|
||||
stopPlayback,
|
||||
} = useAudioPlayer()
|
||||
|
||||
/* ── 上传音频 mutation ── */
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (data: {
|
||||
file: File
|
||||
name: string
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
}) => {
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
/* 获取或创建默认配音库 */
|
||||
const libs = await queryClient.fetchQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
})
|
||||
const lib = libs.find((l) => l.kind === "voice")
|
||||
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
|
||||
// ── 克隆音色操作 ──────────────────────────────────────
|
||||
const {
|
||||
detailVoice,
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
handleCloneDelete,
|
||||
handleCloneRetry,
|
||||
handleCloneUse,
|
||||
handleShowDetail,
|
||||
handleCloseDetail,
|
||||
handleCloneSuccess,
|
||||
} = useCloneOperations({ showToast })
|
||||
|
||||
/* 直传文件 */
|
||||
const { storage_key } = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
// ── TTS 合成 ─────────────────────────────────────────
|
||||
const {
|
||||
ttsOpen,
|
||||
ttsText,
|
||||
ttsVoiceId,
|
||||
ttsSpeed,
|
||||
ttsStatus,
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
setTtsSpeed,
|
||||
setTtsOpen,
|
||||
handleTtsSynthesize,
|
||||
handleTtsSave,
|
||||
handleTtsClose,
|
||||
} = useTtsSynthesize({ presetVoices, showToast })
|
||||
|
||||
/* 获取音频时长 */
|
||||
const duration = await getAudioDuration(data.file)
|
||||
// ── 上传音频 ──────────────────────────────────────────
|
||||
const {
|
||||
uploadOpen,
|
||||
uploadFile,
|
||||
uploadName,
|
||||
uploadGender,
|
||||
uploadDesc,
|
||||
uploadProgress,
|
||||
setUploadName,
|
||||
setUploadGender,
|
||||
setUploadDesc,
|
||||
setUploadOpen,
|
||||
handleFileSelect,
|
||||
handleFileRemove,
|
||||
handleUpload,
|
||||
handleUploadClose,
|
||||
} = useVoiceUpload({ showToast })
|
||||
|
||||
/* 创建素材记录 */
|
||||
await createAsset({
|
||||
library_id: lib.id,
|
||||
name: data.name,
|
||||
storage_key,
|
||||
mime_type: data.file.type || "audio/mpeg",
|
||||
metadata: buildVoiceMetadata({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
duration,
|
||||
}),
|
||||
})
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
setUploadOpen(false)
|
||||
setUploadFile(null)
|
||||
setUploadName("")
|
||||
setUploadDesc("")
|
||||
showToast("上传成功", "success")
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
showToast(err.message || "上传失败,请重试", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/* ── TTS 合成 ── */
|
||||
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, 50) })
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
showToast("已保存到配音库", "success")
|
||||
setTtsOpen(false)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "保存失败"
|
||||
showToast(msg, "error")
|
||||
}
|
||||
}, [ttsJobId, ttsText, queryClient, showToast])
|
||||
|
||||
/* ── TTS 定时器清理 ── */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (ttsTimerRef.current) clearInterval(ttsTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 数据查询(任务 3.11:替换 Mock) ──────────────── */
|
||||
|
||||
/** 预置音色列表 */
|
||||
const { data: presetData, isLoading: presetLoading } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
})
|
||||
|
||||
/** 克隆音色列表 */
|
||||
const { data: cloneData, isLoading: cloneLoading } = useQuery({
|
||||
queryKey: ["voice-clones"],
|
||||
queryFn: () => getVoiceClonesWithTotal({ limit: 50 }),
|
||||
})
|
||||
|
||||
/** 配音素材列表(用户上传音频) */
|
||||
const { data: materialData, isLoading: materialLoading } = useQuery({
|
||||
queryKey: ["voice-materials"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
/** 统一统计(preset_count / clone_count) */
|
||||
const { data: unifiedStats } = useQuery({
|
||||
queryKey: ["voices-unified"],
|
||||
queryFn: () => fetchVoices({ limit: 1 }),
|
||||
})
|
||||
|
||||
const presetVoices = useMemo(
|
||||
() => (presetData?.items ?? []).map(mapPresetToDisplay),
|
||||
[presetData],
|
||||
)
|
||||
const clonedVoices = useMemo(
|
||||
() => (cloneData?.items ?? []).map((p) => mapCloneToDisplay(toVoiceClone(p))),
|
||||
[cloneData],
|
||||
)
|
||||
const presetCount = unifiedStats?.preset_count ?? presetData?.total ?? 0
|
||||
const cloneCount = unifiedStats?.clone_count ?? cloneData?.total ?? 0
|
||||
const materialCount = materialData?.length ?? 0
|
||||
|
||||
const filteredPreset = useMemo(() => {
|
||||
let list = presetVoices
|
||||
if (filterGender !== "all") {
|
||||
list = list.filter((v) => v.gender === filterGender)
|
||||
}
|
||||
if (filterLang !== "all") {
|
||||
list = list.filter((v) => v.language === filterLang)
|
||||
}
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter(
|
||||
(v) =>
|
||||
v.name.toLowerCase().includes(q) ||
|
||||
v.description.toLowerCase().includes(q) ||
|
||||
v.tags.some((tag) => tag.toLowerCase().includes(q)),
|
||||
)
|
||||
}
|
||||
return list
|
||||
}, [presetVoices, filterGender, filterLang, searchText])
|
||||
|
||||
const handlePlay = useCallback(
|
||||
// ── 克隆音色播放切换 ──────────────────────────────────
|
||||
const handleClonePlayPause = useCallback(
|
||||
(voiceId: string, duration: number) => {
|
||||
if (playingId === voiceId) return
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
setPlayingId(voiceId)
|
||||
setCurrentTime(0)
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setCurrentTime((prev) => {
|
||||
if (prev >= duration) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setPlayingId(null)
|
||||
return 0
|
||||
}
|
||||
return prev + 0.1
|
||||
})
|
||||
}, 100)
|
||||
handleTogglePlay(voiceId, duration)
|
||||
},
|
||||
[playingId],
|
||||
[handleTogglePlay],
|
||||
)
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setPlayingId(null)
|
||||
}, [])
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(voiceId: string, time: number, duration: number) => {
|
||||
setCurrentTime(time)
|
||||
if (playingId !== voiceId) {
|
||||
handlePlay(voiceId, duration)
|
||||
}
|
||||
// ── 切换 Tab 时停止播放 ───────────────────────────────
|
||||
const handleTabChange = useCallback(
|
||||
(tab: typeof activeTab) => {
|
||||
setActiveTab(tab)
|
||||
stopPlayback()
|
||||
},
|
||||
[playingId, handlePlay],
|
||||
[setActiveTab, stopPlayback],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleToggleStar = (_voiceId: string) => {
|
||||
// TODO: 后端暂未提供收藏接口
|
||||
}
|
||||
|
||||
/** 克隆音色操作 handlers */
|
||||
const handleCloneDelete = (voice: ClonedVoiceDisplay) => {
|
||||
if (window.confirm(`确定删除音色「${voice.name}」吗?`)) {
|
||||
deleteMutation.mutate(voice.id)
|
||||
if (detailVoice?.id === voice.id) setDetailVoice(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloneRetry = (voice: ClonedVoiceDisplay) => {
|
||||
retryMutation.mutate(voice.id)
|
||||
}
|
||||
|
||||
const handleCloneUse = (_voice: ClonedVoiceDisplay) => {
|
||||
showToast("已选择音色", "success")
|
||||
}
|
||||
|
||||
const handleShowDetail = (voice: ClonedVoiceDisplay) => {
|
||||
setDetailVoice(voice)
|
||||
}
|
||||
|
||||
const handleClonePlayPause = (voiceId: string, duration: number) => {
|
||||
if (playingId === voiceId) {
|
||||
handlePause()
|
||||
} else {
|
||||
handlePlay(voiceId, duration)
|
||||
}
|
||||
}
|
||||
|
||||
/** 克隆音色成功回调 */
|
||||
const handleCloneSuccess = (_voice: VoiceClone) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-clones"] })
|
||||
showToast("克隆已提交,正在生成中", "success")
|
||||
}
|
||||
// ── 清除筛选条件 ──────────────────────────────────────
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setSearchText("")
|
||||
setFilterGender("all")
|
||||
setFilterLang("all")
|
||||
}, [setSearchText, setFilterGender, setFilterLang])
|
||||
|
||||
// ── 页面操作按钮 ──────────────────────────────────────
|
||||
const pageActions = (
|
||||
<div className="xx-voices-actions">
|
||||
<Button
|
||||
@@ -438,13 +200,11 @@ const VoiceLibrary: React.FC = () => {
|
||||
actions={pageActions}
|
||||
/>
|
||||
|
||||
{/* ── Tabs ──────────────────────────────────────── */}
|
||||
<div className="xx-voices-tabs">
|
||||
<button
|
||||
className={`xx-voices-tab${activeTab === "preset" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setActiveTab("preset")
|
||||
handlePause()
|
||||
}}
|
||||
onClick={() => handleTabChange("preset")}
|
||||
>
|
||||
<AudioOutlined />
|
||||
预置音色
|
||||
@@ -452,10 +212,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
className={`xx-voices-tab${activeTab === "cloned" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setActiveTab("cloned")
|
||||
handlePause()
|
||||
}}
|
||||
onClick={() => handleTabChange("cloned")}
|
||||
>
|
||||
<UserOutlined />
|
||||
我的克隆
|
||||
@@ -463,10 +220,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
className={`xx-voices-tab${activeTab === "material" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setActiveTab("material")
|
||||
handlePause()
|
||||
}}
|
||||
onClick={() => handleTabChange("material")}
|
||||
>
|
||||
<SoundOutlined />
|
||||
配音素材
|
||||
@@ -474,6 +228,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── 预置音色 ──────────────────────────────────── */}
|
||||
{activeTab === "preset" && (
|
||||
<div className="xx-voices-tab-content">
|
||||
<VoiceFilterBar
|
||||
@@ -512,7 +267,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
onPlay={() => handlePlay(voice.id, voice.duration)}
|
||||
onPause={handlePause}
|
||||
onSeek={(time) => handleSeek(voice.id, time, voice.duration)}
|
||||
onToggleStar={() => handleToggleStar(voice.voiceId)}
|
||||
onToggleStar={() => {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -524,15 +279,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<p>未找到匹配的音色</p>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
setSearchText("")
|
||||
setFilterGender("all")
|
||||
setFilterLang("all")
|
||||
}}
|
||||
>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={handleClearFilters}>
|
||||
清除筛选条件
|
||||
</Button>
|
||||
</div>
|
||||
@@ -540,6 +287,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 我的克隆 ──────────────────────────────────── */}
|
||||
{activeTab === "cloned" && (
|
||||
<div className="xx-voices-tab-content">
|
||||
{/* 骨架屏加载 */}
|
||||
@@ -595,6 +343,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 配音素材 ──────────────────────────────────── */}
|
||||
{activeTab === "material" && (
|
||||
<div className="xx-voices-tab-content">
|
||||
{/* 骨架屏加载 */}
|
||||
@@ -613,9 +362,9 @@ const VoiceLibrary: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 卡片列表 */}
|
||||
{!materialLoading && (materialData?.length || 0) > 0 && (
|
||||
{!materialLoading && materials.length > 0 && (
|
||||
<div className="xx-voice-grid">
|
||||
{(materialData || []).map((asset: AssetItem) => {
|
||||
{materials.map((asset: AssetItem) => {
|
||||
const duration = (asset.metadata?.duration as number) || 0
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = Math.floor(duration % 60)
|
||||
@@ -646,7 +395,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!materialLoading && (materialData?.length || 0) === 0 && (
|
||||
{!materialLoading && materials.length === 0 && (
|
||||
<div className="xx-voices-empty">
|
||||
<div className="xx-voices-empty-icon">
|
||||
<SoundOutlined />
|
||||
@@ -661,25 +410,25 @@ const VoiceLibrary: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 克隆音色弹窗 */}
|
||||
{/* ── 克隆音色弹窗 ──────────────────────────────── */}
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
onClose={() => setCloneModalOpen(false)}
|
||||
onSuccess={handleCloneSuccess}
|
||||
/>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
{/* ── 详情弹窗 ──────────────────────────────────── */}
|
||||
{detailVoice && (
|
||||
<CloneDetailModal
|
||||
voice={detailVoice}
|
||||
onClose={() => setDetailVoice(null)}
|
||||
onClose={handleCloseDetail}
|
||||
onDelete={() => handleCloneDelete(detailVoice)}
|
||||
onRetry={() => handleCloneRetry(detailVoice)}
|
||||
onUse={() => handleCloneUse(detailVoice)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 上传音频弹窗 ── */}
|
||||
{/* ── 上传音频弹窗 ──────────────────────────────── */}
|
||||
<UploadVoiceModal
|
||||
open={uploadOpen}
|
||||
uploadFile={uploadFile}
|
||||
@@ -687,34 +436,16 @@ const VoiceLibrary: React.FC = () => {
|
||||
uploadGender={uploadGender}
|
||||
uploadDesc={uploadDesc}
|
||||
uploadProgress={uploadProgress}
|
||||
onClose={() => {
|
||||
setUploadOpen(false)
|
||||
setUploadFile(null)
|
||||
setUploadName("")
|
||||
setUploadDesc("")
|
||||
}}
|
||||
onFileSelect={(file) => {
|
||||
setUploadFile(file)
|
||||
if (!uploadName) setUploadName(file.name.replace(/\.[^.]+$/, ""))
|
||||
}}
|
||||
onFileRemove={() => {
|
||||
setUploadFile(null)
|
||||
setUploadProgress(null)
|
||||
}}
|
||||
onClose={handleUploadClose}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFileRemove={handleFileRemove}
|
||||
onNameChange={setUploadName}
|
||||
onGenderChange={setUploadGender}
|
||||
onDescChange={setUploadDesc}
|
||||
onUpload={() =>
|
||||
uploadMutation.mutate({
|
||||
file: uploadFile!,
|
||||
name: uploadName.trim(),
|
||||
gender: uploadGender,
|
||||
description: uploadDesc.trim(),
|
||||
})
|
||||
}
|
||||
onUpload={handleUpload}
|
||||
/>
|
||||
|
||||
{/* ── AI 配音弹窗 ── */}
|
||||
{/* ── AI 配音弹窗 ───────────────────────────────── */}
|
||||
<TtsModal
|
||||
open={ttsOpen}
|
||||
ttsText={ttsText}
|
||||
@@ -724,16 +455,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
ttsAudioUrl={ttsAudioUrl}
|
||||
ttsError={ttsError}
|
||||
presetVoices={presetVoices}
|
||||
onClose={() => {
|
||||
setTtsOpen(false)
|
||||
setTtsText("")
|
||||
setTtsVoiceId("")
|
||||
setTtsSpeed(1.0)
|
||||
setTtsStatus("idle")
|
||||
setTtsAudioUrl(null)
|
||||
setTtsError(null)
|
||||
setTtsJobId(null)
|
||||
}}
|
||||
onClose={handleTtsClose}
|
||||
onTextChange={setTtsText}
|
||||
onVoiceChange={setTtsVoiceId}
|
||||
onSpeedChange={setTtsSpeed}
|
||||
@@ -741,7 +463,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
onSave={handleTtsSave}
|
||||
/>
|
||||
|
||||
{/* Toast 提示 */}
|
||||
{/* ── Toast 提示 ────────────────────────────────── */}
|
||||
{toasts.length > 0 && (
|
||||
<div className="vc-toast-container">
|
||||
{toasts.map((t) => (
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
|
||||
/**
|
||||
* 音频播放控制 Hook
|
||||
* 封装当前播放状态、播放/暂停/跳转控制,使用 setInterval 模拟进度更新
|
||||
* (适用于预置音色/克隆音色卡片的播放按钮交互)
|
||||
*/
|
||||
export function useAudioPlayer() {
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const intervalRef = useRef<number | null>(null)
|
||||
|
||||
/** 开始播放指定音色(从 startTime 开始,默认从 0 开始) */
|
||||
const handlePlay = useCallback(
|
||||
(voiceId: string, duration: number, startTime: number = 0) => {
|
||||
if (playingId === voiceId) return
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
setPlayingId(voiceId)
|
||||
setCurrentTime(startTime)
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setCurrentTime((prev) => {
|
||||
if (prev >= duration) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setPlayingId(null)
|
||||
return 0
|
||||
}
|
||||
return prev + 0.1
|
||||
})
|
||||
}, 100)
|
||||
},
|
||||
[playingId],
|
||||
)
|
||||
|
||||
/** 暂停播放 */
|
||||
const handlePause = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setPlayingId(null)
|
||||
}, [])
|
||||
|
||||
/** 跳转到指定时间 */
|
||||
const handleSeek = useCallback(
|
||||
(voiceId: string, time: number, duration: number) => {
|
||||
if (playingId !== voiceId) {
|
||||
// 不同音色:从指定时间开始播放
|
||||
handlePlay(voiceId, duration, time)
|
||||
} else {
|
||||
// 同一音色:直接跳转
|
||||
setCurrentTime(time)
|
||||
}
|
||||
},
|
||||
[playingId, handlePlay],
|
||||
)
|
||||
|
||||
/** 切换播放/暂停 */
|
||||
const handleTogglePlay = useCallback(
|
||||
(voiceId: string, duration: number) => {
|
||||
if (playingId === voiceId) {
|
||||
handlePause()
|
||||
} else {
|
||||
handlePlay(voiceId, duration)
|
||||
}
|
||||
},
|
||||
[playingId, handlePlay, handlePause],
|
||||
)
|
||||
|
||||
/** 停止所有播放(切换 Tab 时调用) */
|
||||
const stopPlayback = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setPlayingId(null)
|
||||
setCurrentTime(0)
|
||||
}, [])
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
playingId,
|
||||
currentTime,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleSeek,
|
||||
handleTogglePlay,
|
||||
stopPlayback,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { deleteVoiceClone, retryVoiceClone, type VoiceClone } from "@/api/voice-clone"
|
||||
import { type ClonedVoiceDisplay } from "../types"
|
||||
|
||||
/**
|
||||
* 克隆音色操作 Hook
|
||||
* 封装删除、重试、详情弹窗等克隆音色相关操作
|
||||
*/
|
||||
interface ToastShowFn {
|
||||
(message: string, type: "success" | "error"): void
|
||||
}
|
||||
|
||||
interface UseCloneOperationsProps {
|
||||
showToast: ToastShowFn
|
||||
}
|
||||
|
||||
export function useCloneOperations({ showToast }: UseCloneOperationsProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [detailVoice, setDetailVoice] = useState<ClonedVoiceDisplay | null>(null)
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
|
||||
/** 删除克隆音色 */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteVoiceClone,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-clones"] })
|
||||
showToast("音色已删除", "success")
|
||||
},
|
||||
onError: () => {
|
||||
showToast("删除失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 重试克隆 */
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryVoiceClone,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-clones"] })
|
||||
showToast("已重新提交克隆", "success")
|
||||
},
|
||||
onError: () => {
|
||||
showToast("重试失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
const handleCloneDelete = useCallback(
|
||||
(voice: ClonedVoiceDisplay) => {
|
||||
if (window.confirm(`确定删除音色「${voice.name}」吗?`)) {
|
||||
deleteMutation.mutate(voice.id)
|
||||
if (detailVoice?.id === voice.id) setDetailVoice(null)
|
||||
}
|
||||
},
|
||||
[deleteMutation, detailVoice],
|
||||
)
|
||||
|
||||
const handleCloneRetry = useCallback(
|
||||
(voice: ClonedVoiceDisplay) => {
|
||||
retryMutation.mutate(voice.id)
|
||||
},
|
||||
[retryMutation],
|
||||
)
|
||||
|
||||
const handleCloneUse = useCallback(
|
||||
(_voice: ClonedVoiceDisplay) => {
|
||||
showToast("已选择音色", "success")
|
||||
},
|
||||
[showToast],
|
||||
)
|
||||
|
||||
const handleShowDetail = useCallback((voice: ClonedVoiceDisplay) => {
|
||||
setDetailVoice(voice)
|
||||
}, [])
|
||||
|
||||
const handleCloseDetail = useCallback(() => {
|
||||
setDetailVoice(null)
|
||||
}, [])
|
||||
|
||||
/** 克隆成功回调 — 刷新列表 */
|
||||
const handleCloneSuccess = useCallback(
|
||||
(_voice: VoiceClone) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-clones"] })
|
||||
showToast("克隆已提交,正在生成中", "success")
|
||||
},
|
||||
[queryClient, showToast],
|
||||
)
|
||||
|
||||
return {
|
||||
// 状态
|
||||
detailVoice,
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
// Mutations
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isRetrying: retryMutation.isPending,
|
||||
// Handlers
|
||||
handleCloneDelete,
|
||||
handleCloneRetry,
|
||||
handleCloneUse,
|
||||
handleShowDetail,
|
||||
handleCloseDetail,
|
||||
handleCloneSuccess,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { type PresetVoiceDisplay } from "../types"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
/**
|
||||
* TTS 合成 Hook
|
||||
* 封装 AI 配音弹窗状态、合成请求、轮询、保存到素材库等逻辑
|
||||
*/
|
||||
interface UseTtsSynthesizeProps {
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
showToast: (message: string, type: "success" | "error") => void
|
||||
}
|
||||
|
||||
export function useTtsSynthesize({ presetVoices, showToast }: UseTtsSynthesizeProps) {
|
||||
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)
|
||||
|
||||
/** 开始 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, 50) })
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
|
||||
showToast("已保存到配音库", "success")
|
||||
setTtsOpen(false)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "保存失败"
|
||||
showToast(msg, "error")
|
||||
}
|
||||
}, [ttsJobId, ttsText, queryClient, showToast])
|
||||
|
||||
/** 关闭 TTS 弹窗并清理状态 */
|
||||
const handleTtsClose = useCallback(() => {
|
||||
setTtsOpen(false)
|
||||
setTtsText("")
|
||||
setTtsVoiceId("")
|
||||
setTtsSpeed(1.0)
|
||||
setTtsStatus("idle")
|
||||
setTtsAudioUrl(null)
|
||||
setTtsError(null)
|
||||
setTtsJobId(null)
|
||||
if (ttsTimerRef.current) {
|
||||
clearInterval(ttsTimerRef.current)
|
||||
ttsTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 打开 TTS 弹窗,可选指定音色 */
|
||||
const openTtsWithVoice = useCallback((voiceId?: string) => {
|
||||
setTtsOpen(true)
|
||||
if (voiceId) setTtsVoiceId(voiceId)
|
||||
}, [])
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (ttsTimerRef.current) clearInterval(ttsTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
ttsOpen,
|
||||
ttsText,
|
||||
ttsVoiceId,
|
||||
ttsSpeed,
|
||||
ttsJobId,
|
||||
ttsStatus,
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
// 可选音色列表
|
||||
ttsPresetVoices: presetVoices,
|
||||
// Setters
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
setTtsSpeed,
|
||||
setTtsOpen,
|
||||
// Actions
|
||||
handleTtsSynthesize,
|
||||
handleTtsSave,
|
||||
handleTtsClose,
|
||||
openTtsWithVoice,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type VoiceGender } from "../types"
|
||||
import { uploadAssetDirect, getAssetLibraries, createAsset } from "@/api/assets"
|
||||
import { getAudioDuration } from "../utils/audio"
|
||||
import { buildVoiceMetadata } from "../types"
|
||||
|
||||
/**
|
||||
* 配音上传 Hook
|
||||
* 封装上传音频弹窗状态、上传进度、上传 mutation 逻辑
|
||||
*/
|
||||
interface UseVoiceUploadProps {
|
||||
showToast: (message: string, type: "success" | "error") => void
|
||||
}
|
||||
|
||||
export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null)
|
||||
const [uploadName, setUploadName] = useState("")
|
||||
const [uploadGender, setUploadGender] = useState<VoiceGender>("female")
|
||||
const [uploadDesc, setUploadDesc] = useState("")
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (data: {
|
||||
file: File
|
||||
name: string
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
}) => {
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
/* 获取或创建默认配音库 */
|
||||
const libs = await queryClient.fetchQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
})
|
||||
const lib = libs.find((l) => l.kind === "voice")
|
||||
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
|
||||
|
||||
/* 直传文件 */
|
||||
const { storage_key } = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
/* 获取音频时长 */
|
||||
const duration = await getAudioDuration(data.file)
|
||||
|
||||
/* 创建素材记录 */
|
||||
await createAsset({
|
||||
library_id: lib.id,
|
||||
name: data.name,
|
||||
storage_key,
|
||||
mime_type: data.file.type || "audio/mpeg",
|
||||
metadata: buildVoiceMetadata({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
duration,
|
||||
}),
|
||||
})
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
|
||||
showToast("上传成功", "success")
|
||||
handleUploadClose()
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
showToast(err.message || "上传失败,请重试", "error")
|
||||
},
|
||||
})
|
||||
|
||||
const handleUploadClose = useCallback(() => {
|
||||
setUploadOpen(false)
|
||||
setUploadFile(null)
|
||||
setUploadName("")
|
||||
setUploadDesc("")
|
||||
setUploadProgress(null)
|
||||
}, [])
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(file: File) => {
|
||||
setUploadFile(file)
|
||||
if (!uploadName) setUploadName(file.name.replace(/\.[^.]+$/, ""))
|
||||
},
|
||||
[uploadName],
|
||||
)
|
||||
|
||||
const handleFileRemove = useCallback(() => {
|
||||
setUploadFile(null)
|
||||
setUploadProgress(null)
|
||||
}, [])
|
||||
|
||||
const handleUpload = useCallback(() => {
|
||||
if (!uploadFile) return
|
||||
uploadMutation.mutate({
|
||||
file: uploadFile,
|
||||
name: uploadName.trim(),
|
||||
gender: uploadGender,
|
||||
description: uploadDesc.trim(),
|
||||
})
|
||||
}, [uploadFile, uploadName, uploadGender, uploadDesc, uploadMutation])
|
||||
|
||||
return {
|
||||
// 弹窗状态
|
||||
uploadOpen,
|
||||
setUploadOpen,
|
||||
// 表单状态
|
||||
uploadFile,
|
||||
uploadName,
|
||||
uploadGender,
|
||||
uploadDesc,
|
||||
uploadProgress,
|
||||
isUploading: uploadMutation.isPending,
|
||||
// Setters
|
||||
setUploadName,
|
||||
setUploadGender,
|
||||
setUploadDesc,
|
||||
// Handlers
|
||||
handleFileSelect,
|
||||
handleFileRemove,
|
||||
handleUpload,
|
||||
handleUploadClose,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { fetchPresetVoices, fetchVoices } from "@/api/voices"
|
||||
import { getVoiceClonesWithTotal, toVoiceClone } from "@/api/voice-clone"
|
||||
import { getAssetsByKind, type AssetItem } from "@/api/assets"
|
||||
import {
|
||||
type TabKey,
|
||||
type ClonedVoiceDisplay,
|
||||
type PresetVoiceDisplay,
|
||||
mapPresetToDisplay,
|
||||
mapCloneToDisplay,
|
||||
} from "../types"
|
||||
|
||||
/**
|
||||
* 配音库数据 Hook
|
||||
* 封装三个 Tab 的数据查询、筛选状态管理、数据映射逻辑
|
||||
*/
|
||||
export function useVoicesData() {
|
||||
// ── Tab & 筛选状态 ─────────────────────────────────────
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("preset")
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterGender, setFilterGender] = useState<string>("all")
|
||||
const [filterLang, setFilterLang] = useState<string>("all")
|
||||
|
||||
// ── 数据查询 ───────────────────────────────────────────
|
||||
|
||||
/** 预置音色列表 */
|
||||
const { data: presetData, isLoading: presetLoading } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
})
|
||||
|
||||
/** 克隆音色列表 */
|
||||
const { data: cloneData, isLoading: cloneLoading } = useQuery({
|
||||
queryKey: ["voice-clones"],
|
||||
queryFn: () => getVoiceClonesWithTotal({ limit: 50 }),
|
||||
})
|
||||
|
||||
/** 配音素材列表(用户上传音频) */
|
||||
const { data: materialData, isLoading: materialLoading } = useQuery({
|
||||
queryKey: ["voice-materials"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
/** 统一统计(preset_count / clone_count) */
|
||||
const { data: unifiedStats } = useQuery({
|
||||
queryKey: ["voices-unified"],
|
||||
queryFn: () => fetchVoices({ limit: 1 }),
|
||||
})
|
||||
|
||||
// ── 数据映射 ─────────────────────────────────────────
|
||||
|
||||
const presetVoices: PresetVoiceDisplay[] = useMemo(
|
||||
() => (presetData?.items ?? []).map(mapPresetToDisplay),
|
||||
[presetData],
|
||||
)
|
||||
|
||||
const clonedVoices: ClonedVoiceDisplay[] = useMemo(
|
||||
() => (cloneData?.items ?? []).map((p) => mapCloneToDisplay(toVoiceClone(p))),
|
||||
[cloneData],
|
||||
)
|
||||
|
||||
const materials: AssetItem[] = useMemo(() => materialData ?? [], [materialData])
|
||||
|
||||
// ── 计数 ──────────────────────────────────────────────
|
||||
|
||||
const presetCount = unifiedStats?.preset_count ?? presetData?.total ?? 0
|
||||
const cloneCount = unifiedStats?.clone_count ?? cloneData?.total ?? 0
|
||||
const materialCount = materialData?.length ?? 0
|
||||
|
||||
// ── 预置音色筛选 ─────────────────────────────────────
|
||||
|
||||
const filteredPreset = useMemo(() => {
|
||||
let list = presetVoices
|
||||
if (filterGender !== "all") {
|
||||
list = list.filter((v) => v.gender === filterGender)
|
||||
}
|
||||
if (filterLang !== "all") {
|
||||
list = list.filter((v) => v.language === filterLang)
|
||||
}
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter(
|
||||
(v) =>
|
||||
v.name.toLowerCase().includes(q) ||
|
||||
v.description.toLowerCase().includes(q) ||
|
||||
v.tags.some((tag) => tag.toLowerCase().includes(q)),
|
||||
)
|
||||
}
|
||||
return list
|
||||
}, [presetVoices, filterGender, filterLang, searchText])
|
||||
|
||||
return {
|
||||
// Tab 状态
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
// 筛选状态
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterGender,
|
||||
setFilterGender,
|
||||
filterLang,
|
||||
setFilterLang,
|
||||
// 加载状态
|
||||
presetLoading,
|
||||
cloneLoading,
|
||||
materialLoading,
|
||||
// 原始数据
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
materials,
|
||||
// 筛选后数据
|
||||
filteredPreset,
|
||||
// 计数
|
||||
presetCount,
|
||||
cloneCount,
|
||||
materialCount,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* useAudioPlayer hook 测试 — VoiceLibrary 版本
|
||||
*
|
||||
* 该 Hook 使用 setInterval 模拟音频播放进度,纯逻辑可测。
|
||||
* 参考 voice-materials/hooks/useAudioPlayer.test.ts 的测试结构。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"
|
||||
import { renderHook, act } from "@testing-library/react"
|
||||
import { useAudioPlayer } from "@/pages/voices/hooks/useAudioPlayer"
|
||||
|
||||
describe("useAudioPlayer (voices)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("应该使用初始状态初始化", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("handlePlay 应该开始播放指定音色", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("handlePlay 对同一个音色不应重复启动播放", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
const initialTime = result.current.currentTime
|
||||
|
||||
// 推进一些时间让进度走动
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
const timeAfterAdvance = result.current.currentTime
|
||||
expect(timeAfterAdvance).toBeGreaterThan(initialTime)
|
||||
|
||||
// 对同一个音色再次调用 handlePlay 不应重置
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBe(timeAfterAdvance)
|
||||
})
|
||||
|
||||
it("handlePlay 切换音色时应停止上一个并从头开始", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBeGreaterThan(0)
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-2", 15)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-2")
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("播放进度应该随时间递增", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
// 每 100ms 增加 0.1
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(result.current.currentTime).toBeCloseTo(0.3, 1)
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
})
|
||||
|
||||
it("播放到结尾应自动停止并重置", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 0.5) // 0.5 秒的短音频
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600) // 超过 0.5 秒
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("handlePause 应该暂停播放", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
const timeBeforePause = result.current.currentTime
|
||||
|
||||
act(() => {
|
||||
result.current.handlePause()
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
|
||||
// 暂停后时间不应再变化
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(result.current.currentTime).toBe(timeBeforePause)
|
||||
})
|
||||
|
||||
it("handleSeek 应该跳转到指定时间", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSeek("voice-1", 5, 10)
|
||||
})
|
||||
|
||||
expect(result.current.currentTime).toBe(5)
|
||||
})
|
||||
|
||||
it("handleSeek 对不同音色应该开始播放该音色", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSeek("voice-2", 3, 15)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-2")
|
||||
expect(result.current.currentTime).toBe(3)
|
||||
})
|
||||
|
||||
it("handleTogglePlay 应该在播放和暂停之间切换", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
// 初始为暂停,调用应开始播放
|
||||
act(() => {
|
||||
result.current.handleTogglePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
|
||||
// 再次调用应暂停
|
||||
act(() => {
|
||||
result.current.handleTogglePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
})
|
||||
|
||||
it("stopPlayback 应该重置所有播放状态", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBeGreaterThan(0)
|
||||
|
||||
act(() => {
|
||||
result.current.stopPlayback()
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
|
||||
// 停止后定时器不应再触发
|
||||
const timeAfterStop = result.current.currentTime
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
expect(result.current.currentTime).toBe(timeAfterStop)
|
||||
})
|
||||
|
||||
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.handleTogglePlay).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")
|
||||
})
|
||||
})
|
||||
@@ -26,6 +26,13 @@ import "@/pages/voices/constants"
|
||||
import "@/pages/voices/utils/format"
|
||||
import "@/pages/voices/utils/audio"
|
||||
|
||||
// Hooks
|
||||
import "@/pages/voices/hooks/useVoicesData"
|
||||
import "@/pages/voices/hooks/useAudioPlayer"
|
||||
import "@/pages/voices/hooks/useCloneOperations"
|
||||
import "@/pages/voices/hooks/useTtsSynthesize"
|
||||
import "@/pages/voices/hooks/useVoiceUpload"
|
||||
|
||||
describe("VoiceLibrary module smoke test", () => {
|
||||
it("should load all voice-library modules", () => {
|
||||
// 纯模块加载测试,确保所有组件/工具函数能正常 import
|
||||
|
||||
Reference in New Issue
Block a user