diff --git a/apps/web/src/pages/voices/VoiceLibrary.tsx b/apps/web/src/pages/voices/VoiceLibrary.tsx old mode 100755 new mode 100644 index 99fae2e1a..1fc379c5f --- a/apps/web/src/pages/voices/VoiceLibrary.tsx +++ b/apps/web/src/pages/voices/VoiceLibrary.tsx @@ -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("preset") - const [searchText, setSearchText] = useState("") - const [filterGender, setFilterGender] = useState("all") - const [filterLang, setFilterLang] = useState("all") - - const [playingId, setPlayingId] = useState(null) - const [currentTime, setCurrentTime] = useState(0) - const intervalRef = useRef(null) - - const queryClient = useQueryClient() - const [detailVoice, setDetailVoice] = useState(null) + // ── Toast ───────────────────────────────────────────── const [toasts, setToasts] = useState([]) - const [cloneModalOpen, setCloneModalOpen] = useState(false) - - /* ── 上传音频弹窗状态 ── */ - const [uploadOpen, setUploadOpen] = useState(false) - const [uploadFile, setUploadFile] = useState(null) - const [uploadName, setUploadName] = useState("") - const [uploadGender, setUploadGender] = useState("female") - const [uploadDesc, setUploadDesc] = useState("") - const [uploadProgress, setUploadProgress] = useState(null) - - /* ── AI 配音弹窗状态 ── */ - const [ttsOpen, setTtsOpen] = useState(false) - const [ttsText, setTtsText] = useState("") - const [ttsVoiceId, setTtsVoiceId] = useState("") - const [ttsSpeed, setTtsSpeed] = useState(1.0) - const [ttsJobId, setTtsJobId] = useState(null) - const [ttsStatus, setTtsStatus] = useState("idle") - const [ttsAudioUrl, setTtsAudioUrl] = useState(null) - const [ttsError, setTtsError] = useState(null) - const ttsTimerRef = useRef | null>(null) const 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 = (
+ {/* ── 预置音色 ──────────────────────────────────── */} {activeTab === "preset" && (
{ onPlay={() => handlePlay(voice.id, voice.duration)} onPause={handlePause} onSeek={(time) => handleSeek(voice.id, time, voice.duration)} - onToggleStar={() => handleToggleStar(voice.voiceId)} + onToggleStar={() => {}} /> ))}
@@ -524,15 +279,7 @@ const VoiceLibrary: React.FC = () => {

未找到匹配的音色

- @@ -540,6 +287,7 @@ const VoiceLibrary: React.FC = () => { )} + {/* ── 我的克隆 ──────────────────────────────────── */} {activeTab === "cloned" && (
{/* 骨架屏加载 */} @@ -595,6 +343,7 @@ const VoiceLibrary: React.FC = () => {
)} + {/* ── 配音素材 ──────────────────────────────────── */} {activeTab === "material" && (
{/* 骨架屏加载 */} @@ -613,9 +362,9 @@ const VoiceLibrary: React.FC = () => { )} {/* 卡片列表 */} - {!materialLoading && (materialData?.length || 0) > 0 && ( + {!materialLoading && materials.length > 0 && (
- {(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 && (
@@ -661,25 +410,25 @@ const VoiceLibrary: React.FC = () => {
)} - {/* 克隆音色弹窗 */} + {/* ── 克隆音色弹窗 ──────────────────────────────── */} setCloneModalOpen(false)} onSuccess={handleCloneSuccess} /> - {/* 详情弹窗 */} + {/* ── 详情弹窗 ──────────────────────────────────── */} {detailVoice && ( setDetailVoice(null)} + onClose={handleCloseDetail} onDelete={() => handleCloneDelete(detailVoice)} onRetry={() => handleCloneRetry(detailVoice)} onUse={() => handleCloneUse(detailVoice)} /> )} - {/* ── 上传音频弹窗 ── */} + {/* ── 上传音频弹窗 ──────────────────────────────── */} { 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 配音弹窗 ───────────────────────────────── */} { 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 && (
{toasts.map((t) => ( diff --git a/apps/web/src/pages/voices/hooks/useAudioPlayer.ts b/apps/web/src/pages/voices/hooks/useAudioPlayer.ts new file mode 100644 index 000000000..4dd395495 --- /dev/null +++ b/apps/web/src/pages/voices/hooks/useAudioPlayer.ts @@ -0,0 +1,102 @@ +import { useState, useRef, useCallback, useEffect } from "react" + +/** + * 音频播放控制 Hook + * 封装当前播放状态、播放/暂停/跳转控制,使用 setInterval 模拟进度更新 + * (适用于预置音色/克隆音色卡片的播放按钮交互) + */ +export function useAudioPlayer() { + const [playingId, setPlayingId] = useState(null) + const [currentTime, setCurrentTime] = useState(0) + const intervalRef = useRef(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, + } +} diff --git a/apps/web/src/pages/voices/hooks/useCloneOperations.ts b/apps/web/src/pages/voices/hooks/useCloneOperations.ts new file mode 100644 index 000000000..0129aac47 --- /dev/null +++ b/apps/web/src/pages/voices/hooks/useCloneOperations.ts @@ -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(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, + } +} diff --git a/apps/web/src/pages/voices/hooks/useTtsSynthesize.ts b/apps/web/src/pages/voices/hooks/useTtsSynthesize.ts new file mode 100644 index 000000000..0dd35b055 --- /dev/null +++ b/apps/web/src/pages/voices/hooks/useTtsSynthesize.ts @@ -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("") + const [ttsSpeed, setTtsSpeed] = useState(1.0) + const [ttsJobId, setTtsJobId] = useState(null) + const [ttsStatus, setTtsStatus] = useState("idle") + const [ttsAudioUrl, setTtsAudioUrl] = useState(null) + const [ttsError, setTtsError] = useState(null) + const ttsTimerRef = useRef | null>(null) + + /** 开始 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, + } +} diff --git a/apps/web/src/pages/voices/hooks/useVoiceUpload.ts b/apps/web/src/pages/voices/hooks/useVoiceUpload.ts new file mode 100644 index 000000000..1ece731c8 --- /dev/null +++ b/apps/web/src/pages/voices/hooks/useVoiceUpload.ts @@ -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(null) + const [uploadName, setUploadName] = useState("") + const [uploadGender, setUploadGender] = useState("female") + const [uploadDesc, setUploadDesc] = useState("") + const [uploadProgress, setUploadProgress] = useState(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, + } +} diff --git a/apps/web/src/pages/voices/hooks/useVoicesData.ts b/apps/web/src/pages/voices/hooks/useVoicesData.ts new file mode 100644 index 000000000..9645083af --- /dev/null +++ b/apps/web/src/pages/voices/hooks/useVoicesData.ts @@ -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("preset") + const [searchText, setSearchText] = useState("") + const [filterGender, setFilterGender] = useState("all") + const [filterLang, setFilterLang] = useState("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, + } +} diff --git a/apps/web/src/test/pages/voices/hooks/useAudioPlayer.test.ts b/apps/web/src/test/pages/voices/hooks/useAudioPlayer.test.ts new file mode 100644 index 000000000..da94e45ce --- /dev/null +++ b/apps/web/src/test/pages/voices/hooks/useAudioPlayer.test.ts @@ -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") + }) +}) diff --git a/apps/web/src/test/pages/voices/smoke.test.tsx b/apps/web/src/test/pages/voices/smoke.test.tsx index 29c3de91d..63b6a7625 100755 --- a/apps/web/src/test/pages/voices/smoke.test.tsx +++ b/apps/web/src/test/pages/voices/smoke.test.tsx @@ -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