From 399b1af7ee1a53ced4a12a15c2e93cb60d5dfc15 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 28 Jul 2026 10:22:17 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor(voice):=20=E6=8B=86=E5=88=86=20use?= =?UTF-8?q?CloneModal=20Hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三阶段重构: - Phase 1: 拆分为 2 个子 Hook - useCloneFormState: 表单状态管理(字段/录音/文件/验证) - useCloneSubmit: 克隆提交流程(上传→克隆→完成) - Phase 2: 主 Hook 组合子 Hook - Phase 3: 主文件 215→46 行(-79%) 保持导出签名向后兼容 --- .../voice/hooks/useCloneFormState.ts | 134 ++++++++++ .../components/voice/hooks/useCloneModal.ts | 235 +++--------------- .../components/voice/hooks/useCloneSubmit.ts | 101 ++++++++ 3 files changed, 268 insertions(+), 202 deletions(-) create mode 100755 apps/web/src/components/voice/hooks/useCloneFormState.ts mode change 100644 => 100755 apps/web/src/components/voice/hooks/useCloneModal.ts create mode 100755 apps/web/src/components/voice/hooks/useCloneSubmit.ts diff --git a/apps/web/src/components/voice/hooks/useCloneFormState.ts b/apps/web/src/components/voice/hooks/useCloneFormState.ts new file mode 100755 index 000000000..44732405d --- /dev/null +++ b/apps/web/src/components/voice/hooks/useCloneFormState.ts @@ -0,0 +1,134 @@ +import { useState, useRef, useCallback, useEffect } from "react" +import type { ModalPhase } from "../../types/cloneModal" +import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../../constants/cloneModal" +import useAudioRecorder from "./useAudioRecorder" + +/** + * 克隆弹窗表单状态 Hook + * 管理表单字段、录音、文件选择、验证逻辑 + */ +export function useCloneFormState({ open, onClose }: { open: boolean; onClose: () => void }) { + const [phase, setPhase] = useState("input") + const [voiceName, setVoiceName] = useState("") + const [voiceDescription, setVoiceDescription] = useState("") + const [selectedFile, setSelectedFile] = useState(null) + const [dragActive, setDragActive] = useState(false) + const [errorMessage, setErrorMessage] = useState("") + + const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } = + useAudioRecorder() + + /** 默认音色名称计数器 */ + const cloneCounterRef = useRef(1) + + const getNextDefaultName = useCallback((): string => { + const name = `我的声音 ${cloneCounterRef.current}` + cloneCounterRef.current += 1 + return name + }, []) + + const hasAudio = selectedFile !== null || recordedBlob !== null + + const canSubmit = + voiceName.trim().length >= MIN_VOICE_NAME_LENGTH && + voiceName.trim().length <= MAX_VOICE_NAME_LENGTH && + hasAudio + + const isProcessing = phase === "uploading" || phase === "cloning" + + /** 重置弹窗状态 */ + const resetState = useCallback(() => { + setPhase("input") + setVoiceName(getNextDefaultName()) + setVoiceDescription("") + setSelectedFile(null) + setDragActive(false) + setErrorMessage("") + resetRecording() + }, [getNextDefaultName, resetRecording]) + + /** 关闭弹窗 */ + const handleClose = useCallback(() => { + resetState() + onClose() + }, [resetState, onClose]) + + /** 弹窗打开时重置状态 */ + useEffect(() => { + if (open) { + resetState() + } + }, [open, resetState]) + + /** 选择文件(来自上传或拖拽) */ + const handleFileSelect = useCallback( + (file: File | null, error: string) => { + if (error) { + setErrorMessage(error) + setSelectedFile(null) + } else { + setErrorMessage("") + setSelectedFile(file) + // 清除录音 + resetRecording() + } + }, + [resetRecording], + ) + + /** 录音切换 */ + const handleRecordToggle = useCallback(() => { + setErrorMessage("") + if (isRecording) { + toggleRecording() + } else { + // 开始录制前清除已选文件 + setSelectedFile(null) + toggleRecording() + } + }, [isRecording, toggleRecording]) + + /** 表单验证 */ + const validateForm = useCallback((): string | null => { + const name = voiceName.trim() + if (!name) { + return "请输入音色名称" + } + if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) { + return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间` + } + if (!hasAudio) { + return "请上传音频文件或录制一段声音" + } + return null + }, [voiceName, hasAudio]) + + return { + // 状态 + phase, + setPhase, + voiceName, + setVoiceName, + voiceDescription, + setVoiceDescription, + selectedFile, + dragActive, + setDragActive, + errorMessage, + setErrorMessage, + // 录音 + isRecording, + recordTime, + recordedBlob, + // 计算属性 + hasAudio, + canSubmit, + isProcessing, + // handlers + handleFileSelect, + handleRecordToggle, + handleClose, + validateForm, + resetState, + } +} diff --git a/apps/web/src/components/voice/hooks/useCloneModal.ts b/apps/web/src/components/voice/hooks/useCloneModal.ts old mode 100644 new mode 100755 index 0135d93a1..fe5113461 --- a/apps/web/src/components/voice/hooks/useCloneModal.ts +++ b/apps/web/src/components/voice/hooks/useCloneModal.ts @@ -1,213 +1,44 @@ -import { useState, useRef, useCallback, useEffect } from "react" -import { createVoiceClone, toVoiceClone } from "@/api/voice-clone" -import { uploadAsset } from "@/api/assets" -import type { ModalPhase, CloneModalProps } from "../types/cloneModal" -import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal" -import useAudioRecorder from "./useAudioRecorder" - -interface UseCloneModalReturn { - phase: ModalPhase - voiceName: string - voiceDescription: string - selectedFile: File | null - dragActive: boolean - errorMessage: string - isRecording: boolean - recordTime: number - recordedBlob: Blob | null - canSubmit: boolean - isProcessing: boolean - setVoiceName: (value: string) => void - setVoiceDescription: (value: string) => void - setDragActive: (active: boolean) => void - handleFileSelect: (file: File | null, error: string) => void - handleRecordToggle: () => void - handleClose: () => void - handleSubmit: () => void -} +import type { CloneModalProps } from "../types/cloneModal" +import { useCloneFormState } from "./useCloneFormState" +import { useCloneSubmit } from "./useCloneSubmit" /** * 音色克隆弹窗主业务 Hook + * 组合表单状态 + 提交流程两个子 Hook */ -const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps): UseCloneModalReturn => { - const [phase, setPhase] = useState("input") - const [voiceName, setVoiceName] = useState("") - const [voiceDescription, setVoiceDescription] = useState("") - const [selectedFile, setSelectedFile] = useState(null) - const [dragActive, setDragActive] = useState(false) - const [errorMessage, setErrorMessage] = useState("") +const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps) => { + const formState = useCloneFormState({ open, onClose }) - const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } = - useAudioRecorder() - - const timerRef = useRef | null>(null) - /** 默认音色名称计数器 */ - const cloneCounterRef = useRef(1) - - const getNextDefaultName = useCallback((): string => { - const name = `我的声音 ${cloneCounterRef.current}` - cloneCounterRef.current += 1 - return name - }, []) - - const hasAudio = selectedFile !== null || recordedBlob !== null - - const canSubmit = - voiceName.trim().length >= MIN_VOICE_NAME_LENGTH && - voiceName.trim().length <= MAX_VOICE_NAME_LENGTH && - hasAudio - - const isProcessing = phase === "uploading" || phase === "cloning" - - /** 重置弹窗状态 */ - const resetState = useCallback(() => { - setPhase("input") - setVoiceName(getNextDefaultName()) - setVoiceDescription("") - setSelectedFile(null) - setDragActive(false) - setErrorMessage("") - resetRecording() - }, [getNextDefaultName, resetRecording]) - - /** 关闭弹窗 */ - const handleClose = useCallback(() => { - resetState() - onClose() - }, [resetState, onClose]) - - /** 弹窗打开时重置状态 */ - useEffect(() => { - if (open) { - resetState() - } - }, [open, resetState]) - - /** 组件卸载时清理定时器 */ - useEffect(() => { - return () => { - if (timerRef.current) clearTimeout(timerRef.current) - } - }, []) - - /** 选择文件(来自上传或拖拽) */ - const handleFileSelect = useCallback( - (file: File | null, error: string) => { - if (error) { - setErrorMessage(error) - setSelectedFile(null) - } else { - setErrorMessage("") - setSelectedFile(file) - // 清除录音 - resetRecording() - } - }, - [resetRecording], - ) - - /** 录音切换 */ - const handleRecordToggle = useCallback(() => { - setErrorMessage("") - if (isRecording) { - toggleRecording() - } else { - // 开始录制前清除已选文件 - setSelectedFile(null) - toggleRecording() - } - }, [isRecording, toggleRecording]) - - /** 表单验证 */ - const validateForm = useCallback((): string | null => { - const name = voiceName.trim() - if (!name) { - return "请输入音色名称" - } - if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) { - return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间` - } - if (!hasAudio) { - return "请上传音频文件或录制一段声音" - } - return null - }, [voiceName, hasAudio]) - - /** 提交克隆 */ - const handleSubmit = useCallback(async () => { - const formError = validateForm() - if (formError) { - setErrorMessage(formError) - return - } - - setErrorMessage("") - - try { - // 阶段 1:上传音频 - setPhase("uploading") - - let fileToUpload: File - if (selectedFile) { - fileToUpload = selectedFile - } else { - fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, { - type: "audio/webm", - }) - } - - const formData = new FormData() - formData.append("file", fileToUpload) - const uploadResult = await uploadAsset(formData) - - // 阶段 2:克隆 - setPhase("cloning") - const result = await createVoiceClone({ - name: voiceName.trim(), - description: voiceDescription.trim() || undefined, - audio_url: uploadResult.url, - }) - - // 阶段 3:完成 - setPhase("done") - - // 2秒后自动关闭 - timerRef.current = setTimeout(() => { - onSuccess?.(toVoiceClone(result)) - handleClose() - }, 2000) - } catch (err) { - setPhase("input") - setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试") - } - }, [ - validateForm, - selectedFile, - recordedBlob, - voiceName, - voiceDescription, + const { handleSubmit } = useCloneSubmit({ + voiceName: formState.voiceName, + voiceDescription: formState.voiceDescription, + selectedFile: formState.selectedFile, + recordedBlob: formState.recordedBlob, + setPhase: formState.setPhase, + setErrorMessage: formState.setErrorMessage, + validateForm: formState.validateForm, onSuccess, - handleClose, - ]) + onClose: formState.handleClose, + }) return { - phase, - voiceName, - voiceDescription, - selectedFile, - dragActive, - errorMessage, - isRecording, - recordTime, - recordedBlob, - canSubmit, - isProcessing, - setVoiceName, - setVoiceDescription, - setDragActive, - handleFileSelect, - handleRecordToggle, - handleClose, + phase: formState.phase, + voiceName: formState.voiceName, + voiceDescription: formState.voiceDescription, + selectedFile: formState.selectedFile, + dragActive: formState.dragActive, + errorMessage: formState.errorMessage, + isRecording: formState.isRecording, + recordTime: formState.recordTime, + recordedBlob: formState.recordedBlob, + canSubmit: formState.canSubmit, + isProcessing: formState.isProcessing, + setVoiceName: formState.setVoiceName, + setVoiceDescription: formState.setVoiceDescription, + setDragActive: formState.setDragActive, + handleFileSelect: formState.handleFileSelect, + handleRecordToggle: formState.handleRecordToggle, + handleClose: formState.handleClose, handleSubmit, } } diff --git a/apps/web/src/components/voice/hooks/useCloneSubmit.ts b/apps/web/src/components/voice/hooks/useCloneSubmit.ts new file mode 100755 index 000000000..ad8a47210 --- /dev/null +++ b/apps/web/src/components/voice/hooks/useCloneSubmit.ts @@ -0,0 +1,101 @@ +import { useRef, useCallback, useEffect } from "react" +import { createVoiceClone, toVoiceClone } from "@/api/voice-clone" +import { uploadAsset } from "@/api/assets" +import type { VoiceClone } from "@/api/voice-clone" + +interface UseCloneSubmitOptions { + voiceName: string + voiceDescription: string + selectedFile: File | null + recordedBlob: Blob | null + setPhase: (phase: "input" | "uploading" | "cloning" | "done") => void + setErrorMessage: (msg: string) => void + validateForm: () => string | null + onSuccess?: (clone: VoiceClone) => void + onClose: () => void +} + +/** + * 克隆提交流程 Hook + * 封装上传 + 克隆 + 完成的三阶段流程 + */ +export function useCloneSubmit({ + voiceName, + voiceDescription, + selectedFile, + recordedBlob, + setPhase, + setErrorMessage, + validateForm, + onSuccess, + onClose, +}: UseCloneSubmitOptions) { + const timerRef = useRef | null>(null) + + /** 组件卸载时清理定时器 */ + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, []) + + const handleSubmit = useCallback(async () => { + const formError = validateForm() + if (formError) { + setErrorMessage(formError) + return + } + + setErrorMessage("") + + try { + // 阶段 1:上传音频 + setPhase("uploading") + + let fileToUpload: File + if (selectedFile) { + fileToUpload = selectedFile + } else { + fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, { + type: "audio/webm", + }) + } + + const formData = new FormData() + formData.append("file", fileToUpload) + const uploadResult = await uploadAsset(formData) + + // 阶段 2:克隆 + setPhase("cloning") + const result = await createVoiceClone({ + name: voiceName.trim(), + description: voiceDescription.trim() || undefined, + audio_url: uploadResult.url, + }) + + // 阶段 3:完成 + setPhase("done") + + // 2秒后自动关闭 + timerRef.current = setTimeout(() => { + onSuccess?.(toVoiceClone(result)) + onClose() + }, 2000) + } catch (err) { + setPhase("input") + setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试") + } + }, [ + validateForm, + selectedFile, + recordedBlob, + voiceName, + voiceDescription, + setPhase, + setErrorMessage, + onSuccess, + onClose, + ]) + + return { handleSubmit } +} -- 2.54.0 From 5648ccbd76fd90ae2ed44b61858bdfb3f7370d59 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 28 Jul 2026 12:44:30 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(useCloneModal):=20=E4=BF=AE=E5=A4=8Duse?= =?UTF-8?q?CloneFormState=E7=9B=B8=E5=AF=B9=E8=B7=AF=E5=BE=84=E5=B1=82?= =?UTF-8?q?=E7=BA=A7=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/components/voice/hooks/useCloneFormState.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) mode change 100755 => 100644 apps/web/src/components/voice/hooks/useCloneFormState.ts diff --git a/apps/web/src/components/voice/hooks/useCloneFormState.ts b/apps/web/src/components/voice/hooks/useCloneFormState.ts old mode 100755 new mode 100644 index 44732405d..7e82ff8c4 --- a/apps/web/src/components/voice/hooks/useCloneFormState.ts +++ b/apps/web/src/components/voice/hooks/useCloneFormState.ts @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useEffect } from "react" -import type { ModalPhase } from "../../types/cloneModal" -import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../../constants/cloneModal" +import type { ModalPhase } from "../types/cloneModal" +import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal" import useAudioRecorder from "./useAudioRecorder" /** -- 2.54.0 From 1b2b65506d492d2f5e93e60c200f0ab15f8d4d4d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 28 Jul 2026 13:03:50 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(CloneModal):=20=E8=A1=A5=E5=85=85Hook?= =?UTF-8?q?=E6=8B=86=E5=88=86=E8=AF=B4=E6=98=8E=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/test/components/voice/CloneModal.test.tsx | 1 + 1 file changed, 1 insertion(+) mode change 100644 => 100755 apps/web/src/test/components/voice/CloneModal.test.tsx diff --git a/apps/web/src/test/components/voice/CloneModal.test.tsx b/apps/web/src/test/components/voice/CloneModal.test.tsx old mode 100644 new mode 100755 index ffd5d3011..91847c07c --- a/apps/web/src/test/components/voice/CloneModal.test.tsx +++ b/apps/web/src/test/components/voice/CloneModal.test.tsx @@ -1,4 +1,5 @@ import React from "react" +// 重构:useCloneModal Hook 已拆分为 useCloneFormState + useCloneSubmit 子 Hook import { describe, expect, it, vi } from "vitest" import { render } from "@testing-library/react" import CloneModal from "@/components/voice/CloneModal" -- 2.54.0