From 399b1af7ee1a53ced4a12a15c2e93cb60d5dfc15 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 28 Jul 2026 10:22:17 +0800 Subject: [PATCH 1/4] =?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 98cb613f8280720f8b91fca107d9f663ce703ccf Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 28 Jul 2026 10:25:10 +0800 Subject: [PATCH 2/4] =?UTF-8?q?refactor(generate):=20=E6=8B=86=E5=88=86=20?= =?UTF-8?q?useStep2Materials=20Hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三阶段重构: - Phase 1: 拆分为 2 个子 Hook - useMaterialLibrary: 素材库加载(库列表 + 选中库 + 素材列表) - useSmartMatch: 智能素材匹配(AI匹配/换一批/全选/清空) - Phase 2: 主 Hook 组合子 Hook + 手动选择逻辑 - Phase 3: 主文件 205→79 行(-61%) 保持导出签名向后兼容 --- .../step2-materials/useMaterialLibrary.ts | 41 +++++ .../hooks/step2-materials/useSmartMatch.ts | 143 +++++++++++++++ .../pages/generate/hooks/useStep2Materials.ts | 172 +++--------------- 3 files changed, 207 insertions(+), 149 deletions(-) create mode 100755 apps/web/src/pages/generate/hooks/step2-materials/useMaterialLibrary.ts create mode 100755 apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts mode change 100644 => 100755 apps/web/src/pages/generate/hooks/useStep2Materials.ts diff --git a/apps/web/src/pages/generate/hooks/step2-materials/useMaterialLibrary.ts b/apps/web/src/pages/generate/hooks/step2-materials/useMaterialLibrary.ts new file mode 100755 index 000000000..3adbcfd1b --- /dev/null +++ b/apps/web/src/pages/generate/hooks/step2-materials/useMaterialLibrary.ts @@ -0,0 +1,41 @@ +import { useState, useEffect } from "react" +import { useQuery } from "@tanstack/react-query" +import { getAssets, getAssetLibraries } from "@/api/assets" +import type { AssetItem } from "@/api/assets" + +/** + * 素材库加载 Hook + * 管理素材库列表、当前选中库、素材列表加载 + */ +export function useMaterialLibrary() { + /* ── 素材库数据 API ── */ + const { data: libraries = [] } = useQuery({ + queryKey: ["asset-libraries"], + queryFn: getAssetLibraries, + }) + const [selectedLibraryId, setSelectedLibraryId] = useState("") + + // 自动选中第一个视频库 + useEffect(() => { + if (libraries.length > 0 && !selectedLibraryId) { + setSelectedLibraryId(libraries[0].id) + } + }, [libraries, selectedLibraryId]) + + const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{ + items: AssetItem[] + total: number + }>({ + queryKey: ["generate-assets", selectedLibraryId], + queryFn: () => getAssets(selectedLibraryId), + enabled: !!selectedLibraryId, + }) + + return { + libraries, + selectedLibraryId, + setSelectedLibraryId, + materials, + materialsLoading, + } +} diff --git a/apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts b/apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts new file mode 100755 index 000000000..98bb0840f --- /dev/null +++ b/apps/web/src/pages/generate/hooks/step2-materials/useSmartMatch.ts @@ -0,0 +1,143 @@ +import { useState, useCallback, useMemo } from "react" +import { message } from "antd" +import type { AssetItem } from "@/api/assets" +import { SMART_MATCH_REASONS } from "../../constants" + +interface SmartMatchedResult { + asset: AssetItem + matchScore: number + matchReason: string +} + +interface UseSmartMatchOptions { + materials: { items: AssetItem[]; total: number } + smartSelectedIds: string[] + onSmartSelectedIdsChange: (ids: string[]) => void +} + +/** + * 智能素材匹配 Hook + * 封装 AI 匹配、换一批、全选/清空等逻辑 + */ +export function useSmartMatch({ + materials, + smartSelectedIds, + onSmartSelectedIdsChange, +}: UseSmartMatchOptions) { + const [smartMatchInput, setSmartMatchInput] = useState("") + const [smartMatching, setSmartMatching] = useState(false) + const [smartMatchedResults, setSmartMatchedResults] = useState([]) + const [hasMatched, setHasMatched] = useState(false) + + /* ── 智能素材匹配 ── */ + const handleSmartMatch = useCallback(async () => { + if (!smartMatchInput.trim()) { + message.warning("请先输入视频内容描述") + return + } + if (materials.items.length === 0) { + message.warning("当前视频库暂无素材") + return + } + + setSmartMatching(true) + setHasMatched(true) + + // 模拟 AI 匹配延迟 + await new Promise((resolve) => setTimeout(resolve, 1500)) + + // 从素材库中随机选取 5-8 个作为推荐结果 + const shuffled = [...materials.items].sort(() => Math.random() - 0.5) + const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4)) + const picked = shuffled.slice(0, count) + + const results = picked.map((asset, idx) => ({ + asset, + matchScore: Math.round(85 + Math.random() * 14), // 85-99 分 + matchReason: + SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] + + (Math.random() > 0.5 ? ",画面质感优秀" : ""), + })) + + // 按匹配度从高到低排序 + results.sort((a, b) => b.matchScore - a.matchScore) + + setSmartMatchedResults(results) + // 默认选中匹配度 >= 90 的素材 + const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id) + onSmartSelectedIdsChange( + defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id), + ) + setSmartMatching(false) + }, [smartMatchInput, materials.items, onSmartSelectedIdsChange]) + + const handleToggleSmartSelect = useCallback( + (assetId: string) => { + onSmartSelectedIdsChange( + smartSelectedIds.includes(assetId) + ? smartSelectedIds.filter((id) => id !== assetId) + : [...smartSelectedIds, assetId], + ) + }, + [smartSelectedIds, onSmartSelectedIdsChange], + ) + + const handleRefreshMatch = useCallback(async () => { + if (materials.items.length <= 5) { + message.info("视频库素材较少,无法换一批") + return + } + setSmartMatching(true) + await new Promise((resolve) => setTimeout(resolve, 800)) + + const remaining = materials.items.filter( + (m) => !smartMatchedResults.some((r) => r.asset.id === m.id), + ) + const shuffled = [...remaining].sort(() => Math.random() - 0.5) + const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3)) + const picked = shuffled.slice(0, count) + + const results = picked.map((asset, idx) => ({ + asset, + matchScore: Math.round(80 + Math.random() * 19), + matchReason: + SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] + + (Math.random() > 0.5 ? ",节奏明快" : ""), + })) + results.sort((a, b) => b.matchScore - a.matchScore) + + setSmartMatchedResults(results) + onSmartSelectedIdsChange([]) + setSmartMatching(false) + }, [materials.items, smartMatchedResults, onSmartSelectedIdsChange]) + + const handleSelectAllMatched = useCallback(() => { + onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id)) + }, [smartMatchedResults, onSmartSelectedIdsChange]) + + const handleClearSmartSelect = useCallback(() => { + onSmartSelectedIdsChange([]) + }, [onSmartSelectedIdsChange]) + + /* ── 计算已选智能匹配素材的总时长 ── */ + const smartSelectedTotalDuration = useMemo(() => { + return smartMatchedResults + .filter((r) => smartSelectedIds.includes(r.asset.id)) + .reduce((sum, r) => sum + (r.asset.duration || 0), 0) + }, [smartMatchedResults, smartSelectedIds]) + + return { + smartMatchInput, + setSmartMatchInput, + smartMatching, + smartMatchedResults, + hasMatched, + smartSelectedIds, + handleSmartMatch, + handleToggleSmartSelect, + handleRefreshMatch, + handleSelectAllMatched, + handleClearSmartSelect, + smartSelectedTotalDuration, + } +} diff --git a/apps/web/src/pages/generate/hooks/useStep2Materials.ts b/apps/web/src/pages/generate/hooks/useStep2Materials.ts old mode 100644 new mode 100755 index 127654bfd..3a07d95ef --- a/apps/web/src/pages/generate/hooks/useStep2Materials.ts +++ b/apps/web/src/pages/generate/hooks/useStep2Materials.ts @@ -1,20 +1,11 @@ /** * Step 2 素材选择 Hook - * 封装素材库加载、手动选择、智能匹配等逻辑 + * 组合素材库加载 + 智能匹配两个子 Hook */ -import { useState, useCallback, useEffect, useMemo } from "react" -import { message } from "antd" -import { useQuery } from "@tanstack/react-query" -import { getAssets, getAssetLibraries } from "@/api/assets" -import type { AssetItem } from "@/api/assets" +import { useCallback } from "react" import { formatDuration } from "../utils/formatDuration" -import { SMART_MATCH_REASONS } from "../constants" - -interface SmartMatchedResult { - asset: AssetItem - matchScore: number - matchReason: string -} +import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary" +import { useSmartMatch } from "./step2-materials/useSmartMatch" interface UseStep2MaterialsProps { materialMode: "manual" | "auto" @@ -33,34 +24,14 @@ export function useStep2Materials({ smartSelectedIds, onSmartSelectedIdsChange, }: UseStep2MaterialsProps) { - /* ── 素材库数据 API ── */ - const { data: libraries = [] } = useQuery({ - queryKey: ["asset-libraries"], - queryFn: getAssetLibraries, + const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } = + useMaterialLibrary() + + const smartMatch = useSmartMatch({ + materials, + smartSelectedIds, + onSmartSelectedIdsChange, }) - const [selectedLibraryId, setSelectedLibraryId] = useState("") - - // 自动选中第一个视频库 - useEffect(() => { - if (libraries.length > 0 && !selectedLibraryId) { - setSelectedLibraryId(libraries[0].id) - } - }, [libraries, selectedLibraryId]) - - const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{ - items: AssetItem[] - total: number - }>({ - queryKey: ["generate-assets", selectedLibraryId], - queryFn: () => getAssets(selectedLibraryId), - enabled: !!selectedLibraryId, - }) - - /* ── 智能素材匹配状态 ── */ - const [smartMatchInput, setSmartMatchInput] = useState("") - const [smartMatching, setSmartMatching] = useState(false) - const [smartMatchedResults, setSmartMatchedResults] = useState([]) - const [hasMatched, setHasMatched] = useState(false) /* ── 手动选择素材 ── */ const handleToggleMaterial = useCallback( @@ -74,103 +45,6 @@ export function useStep2Materials({ [selectedMaterials, onSelectedMaterialsChange], ) - /* ── 智能素材匹配 ── */ - const handleSmartMatch = useCallback(async () => { - if (!smartMatchInput.trim()) { - message.warning("请先输入视频内容描述") - return - } - if (materials.items.length === 0) { - message.warning("当前视频库暂无素材") - return - } - - setSmartMatching(true) - setHasMatched(true) - - // 模拟 AI 匹配延迟 - await new Promise((resolve) => setTimeout(resolve, 1500)) - - // 从素材库中随机选取 5-8 个作为推荐结果 - const shuffled = [...materials.items].sort(() => Math.random() - 0.5) - const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4)) - const picked = shuffled.slice(0, count) - - const results = picked.map((asset, idx) => ({ - asset, - matchScore: Math.round(85 + Math.random() * 14), // 85-99 分 - matchReason: - SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] + - (Math.random() > 0.5 ? ",画面质感优秀" : ""), - })) - - // 按匹配度从高到低排序 - results.sort((a, b) => b.matchScore - a.matchScore) - - setSmartMatchedResults(results) - // 默认选中匹配度 >= 90 的素材 - const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id) - onSmartSelectedIdsChange( - defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id), - ) - setSmartMatching(false) - }, [smartMatchInput, materials.items, onSmartSelectedIdsChange]) - - const handleToggleSmartSelect = useCallback( - (assetId: string) => { - onSmartSelectedIdsChange( - smartSelectedIds.includes(assetId) - ? smartSelectedIds.filter((id) => id !== assetId) - : [...smartSelectedIds, assetId], - ) - }, - [smartSelectedIds, onSmartSelectedIdsChange], - ) - - const handleRefreshMatch = useCallback(async () => { - if (materials.items.length <= 5) { - message.info("视频库素材较少,无法换一批") - return - } - setSmartMatching(true) - await new Promise((resolve) => setTimeout(resolve, 800)) - - const remaining = materials.items.filter( - (m) => !smartMatchedResults.some((r) => r.asset.id === m.id), - ) - const shuffled = [...remaining].sort(() => Math.random() - 0.5) - const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3)) - const picked = shuffled.slice(0, count) - - const results = picked.map((asset, idx) => ({ - asset, - matchScore: Math.round(80 + Math.random() * 19), - matchReason: - SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] + - (Math.random() > 0.5 ? ",节奏明快" : ""), - })) - results.sort((a, b) => b.matchScore - a.matchScore) - - setSmartMatchedResults(results) - onSmartSelectedIdsChange([]) - setSmartMatching(false) - }, [materials.items, smartMatchedResults, onSmartSelectedIdsChange]) - - const handleSelectAllMatched = useCallback(() => { - onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id)) - }, [smartMatchedResults, onSmartSelectedIdsChange]) - - const handleClearSmartSelect = useCallback(() => { - onSmartSelectedIdsChange([]) - }, [onSmartSelectedIdsChange]) - - /* ── 计算已选智能匹配素材的总时长 ── */ - const smartSelectedTotalDuration = useMemo(() => { - return smartMatchedResults - .filter((r) => smartSelectedIds.includes(r.asset.id)) - .reduce((sum, r) => sum + (r.asset.duration || 0), 0) - }, [smartMatchedResults, smartSelectedIds]) - return { // 素材库 libraries, @@ -185,18 +59,18 @@ export function useStep2Materials({ selectedMaterials, handleToggleMaterial, // 智能匹配 - smartMatchInput, - setSmartMatchInput, - smartMatching, - smartMatchedResults, - hasMatched, - smartSelectedIds, - handleSmartMatch, - handleToggleSmartSelect, - handleRefreshMatch, - handleSelectAllMatched, - handleClearSmartSelect, - smartSelectedTotalDuration, + smartMatchInput: smartMatch.smartMatchInput, + setSmartMatchInput: smartMatch.setSmartMatchInput, + smartMatching: smartMatch.smartMatching, + smartMatchedResults: smartMatch.smartMatchedResults, + hasMatched: smartMatch.hasMatched, + smartSelectedIds: smartMatch.smartSelectedIds, + handleSmartMatch: smartMatch.handleSmartMatch, + handleToggleSmartSelect: smartMatch.handleToggleSmartSelect, + handleRefreshMatch: smartMatch.handleRefreshMatch, + handleSelectAllMatched: smartMatch.handleSelectAllMatched, + handleClearSmartSelect: smartMatch.handleClearSmartSelect, + smartSelectedTotalDuration: smartMatch.smartSelectedTotalDuration, // utils formatDuration, } -- 2.54.0 From 242d46359b656a7b220f6683378b6259db61a657 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 28 Jul 2026 12:47:13 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(useStep2Materials):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=BB=A7=E6=89=BF=E7=9A=84useCloneFormState=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E9=97=AE=E9=A2=98?= 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 d9a6808d2489c78fc6f8026a5ad814014a7180db Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 28 Jul 2026 13:06:02 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(generate):=20=E8=A1=A5=E5=85=85Step2Ma?= =?UTF-8?q?terials=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/pages/generate/smoke.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/test/pages/generate/smoke.test.tsx b/apps/web/src/test/pages/generate/smoke.test.tsx index 7bb554e50..784ba7b0d 100755 --- a/apps/web/src/test/pages/generate/smoke.test.tsx +++ b/apps/web/src/test/pages/generate/smoke.test.tsx @@ -2,6 +2,9 @@ * GeneratePage 模块 smoke test * 建立完整依赖链,确保 vitest related 模式能匹配到 * generate 目录下所有文件的改动(包括 Phase 3 子组件) + * + * 重构记录: + * - useStep2Materials 拆分为 useMaterialLibrary + useSmartMatch 子 Hook */ import { describe, it, expect } from "vitest" -- 2.54.0