Compare commits

...

6 Commits

Author SHA1 Message Date
xiaoxia 78b0da47a5 fix(voices): handleSeek should start play from seek time for different voice
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 20s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m32s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m33s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 27s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m58s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 11s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m38s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m0s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 27s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m1s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m9s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m11s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 42s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 33s
2026-07-25 15:04:15 +08:00
SaaS Frontend 23a61c7048 chore(voices): fix file permissions to 644
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 8s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 30s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m6s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 36s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m1s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m37s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 23s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m22s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m57s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47m31s
2026-07-25 14:27:56 +08:00
SaaS Frontend efbfeb91b2 test(voices): add useAudioPlayer hook unit test
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 36s
2026-07-25 14:25:39 +08:00
SaaS Frontend 137480d0c6 fix(voices): prettier formatting for Phase 3 hooks
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 30s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 33s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 53s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 56s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m0s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 38s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m0s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m59s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 48m23s
Format all voices files with Prettier to match code style.

Signed-off-by: xiaoxia <xiaoxia@xiaoxiajianji.com>
2026-07-25 14:19:01 +08:00
SaaS Frontend 65a2081aa2 test(voices): add hooks imports to smoke test for vitest related
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 17s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 47s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 33s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 52s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 48s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m36s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m53s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 11s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 16s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m33s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
Explicitly import all 5 voice hooks in smoke test so that
vitest --related can properly detect test coverage for changes
in hook files.

Signed-off-by: xiaoxia <xiaoxia@xiaoxiajianji.com>
2026-07-25 14:11:43 +08:00
SaaS Frontend 7b23e35d96 refactor(voices): Phase 3 - extract 5 hooks from VoiceLibrary
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 25s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 49s
Extract logic into reusable hooks:
- useVoicesData: 3-tab data queries + filter state
- useAudioPlayer: playback control with simulated progress
- useCloneOperations: clone voice delete/retry/detail modal
- useTtsSynthesize: AI TTS synthesis with polling
- useVoiceUpload: audio upload modal + upload mutation

Main file reduced from 758 to 486 lines (-35.9%).
Functionally equivalent, no behavior changes.

Signed-off-by: xiaoxia <xiaoxia@xiaoxiajianji.com>
2026-07-25 14:10:34 +08:00
8 changed files with 973 additions and 410 deletions
+132 -410
View File
@@ -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