diff --git a/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceDelete.ts b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceDelete.ts new file mode 100755 index 000000000..e89b32149 --- /dev/null +++ b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceDelete.ts @@ -0,0 +1,38 @@ +import { useCallback } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { deleteAsset } from "@/api/assets" +import { type VoiceMaterial } from "../../../types" + +interface UseVoiceDeleteOptions { + materials: VoiceMaterial[] +} + +/** + * 配音素材删除 Hook + */ +export function useVoiceDelete({ materials }: UseVoiceDeleteOptions) { + const queryClient = useQueryClient() + + const deleteMutation = useMutation({ + mutationFn: (assetId: string) => deleteAsset(assetId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + }, + }) + + const handleDelete = useCallback( + (id: string, onBeforeDelete?: () => void) => { + const material = materials.find((m) => m.id === id) + if (!material) return + if (onBeforeDelete) onBeforeDelete() + deleteMutation.mutate(id) + }, + [materials, deleteMutation], + ) + + return { + isDeleting: deleteMutation.isPending, + deleteMutation, + handleDelete, + } +} diff --git a/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceEdit.ts b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceEdit.ts new file mode 100755 index 000000000..c53473a27 --- /dev/null +++ b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceEdit.ts @@ -0,0 +1,78 @@ +import { useCallback } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { updateAsset } from "@/api/assets" +import { tagAsset, untagAsset } from "@/api/tags" +import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../../types" + +interface UseVoiceEditOptions { + materials: VoiceMaterial[] +} + +/** + * 配音素材编辑 Hook + * 封装编辑流程:更新基础信息 + 同步标签差异 + */ +export function useVoiceEdit({ materials }: UseVoiceEditOptions) { + const queryClient = useQueryClient() + + const editMutation = useMutation({ + mutationFn: async (data: { + id: string + name: string + gender: VoiceGender + description: string + tagIds: string[] + }) => { + // 1. 更新基础信息 + await updateAsset(data.id, { + name: data.name, + metadata: buildMetadata({ + gender: data.gender, + description: data.description, + }), + }) + + // 2. 对比标签差异,调用 tag/untag API + const currentAsset = materials.find((m) => m.id === data.id) + const oldTagIds = currentAsset?.tagIds ?? [] + const newTagIds = data.tagIds + + const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id)) + const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id)) + + if (toAdd.length > 0) { + await tagAsset(data.id, toAdd) + } + for (const tagId of toRemove) { + await untagAsset(data.id, tagId) + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + queryClient.invalidateQueries({ queryKey: ["tags"] }) + }, + }) + + const handleEdit = useCallback( + ( + editingMaterial: VoiceMaterial | null, + data: Omit & { file?: File }, + ) => { + if (!editingMaterial) return + editMutation.mutate({ + id: editingMaterial.id, + name: data.name, + gender: data.gender, + description: data.description, + tagIds: data.tagIds, + }) + }, + [editMutation], + ) + + return { + isEditing: editMutation.isPending, + editMutation, + handleEdit, + } +} diff --git a/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload.ts b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload.ts new file mode 100755 index 000000000..84ddecc4e --- /dev/null +++ b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload.ts @@ -0,0 +1,111 @@ +import { useState, useCallback } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { message } from "antd" +import { + createAsset, + uploadAssetDirect, + getAssetLibraries, + type AssetLibraryItem, +} from "@/api/assets" +import { tagAsset } from "@/api/tags" +import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../../types" +import { getAudioDuration } from "../../../utils/audio" + +interface UseVoiceUploadOptions { + voiceLibrary?: { id: string; kind: string } + createLibMutation: { mutateAsync: () => Promise; isPending: boolean } +} + +/** + * 配音素材上传 Hook + * 封装上传流程:获取库 → 上传文件 → 获取时长 → 创建记录 → 打标签 + */ +export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUploadOptions) { + const queryClient = useQueryClient() + const [uploadProgress, setUploadProgress] = useState(null) + + const uploadMutation = useMutation({ + mutationFn: async (data: { + file: File + name: string + gender: VoiceGender + description: string + tagIds: string[] + }) => { + setUploadProgress(0) + try { + // 1. 获取或等待 voice library + let lib = voiceLibrary + if (!lib) { + if (createLibMutation.isPending) { + await createLibMutation.mutateAsync() + } + const libs = await queryClient.fetchQuery({ + queryKey: ["asset-libraries"], + queryFn: getAssetLibraries, + }) + lib = libs.find((l: AssetLibraryItem) => l.kind === "voice") + if (!lib) throw new Error("无法创建配音库") + } + + // 2. 上传文件(带进度) + const { storage_key } = await uploadAssetDirect({ + file: data.file, + library_id: lib.id, + onProgress: (p) => setUploadProgress(p), + }) + + // 3. 获取音频时长 + const duration = await getAudioDuration(data.file) + + // 4. 创建素材记录 + const asset = await createAsset({ + library_id: lib.id, + name: data.name, + storage_key, + mime_type: data.file.type || "audio/mpeg", + metadata: buildMetadata({ + gender: data.gender, + description: data.description, + duration, + }), + }) + + // 5. 打标签(标签走独立 API) + if (data.tagIds.length > 0) { + await tagAsset(asset.id, data.tagIds) + } + } finally { + setUploadProgress(null) + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + queryClient.invalidateQueries({ queryKey: ["tags"] }) + }, + onError: (err: Error) => { + message.error(err.message || "上传失败,请重试") + }, + }) + + const handleUpload = useCallback( + (data: Omit & { file?: File }) => { + if (!data.file) return + uploadMutation.mutate({ + file: data.file, + name: data.name, + gender: data.gender, + description: data.description, + tagIds: data.tagIds, + }) + }, + [uploadMutation], + ) + + return { + uploadProgress, + isUploading: uploadMutation.isPending, + uploadMutation, + handleUpload, + } +} diff --git a/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialActions.ts b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialActions.ts old mode 100644 new mode 100755 index bb2be1a54..37dbb1dd5 --- a/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialActions.ts +++ b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialActions.ts @@ -1,17 +1,9 @@ -import { useState, useCallback } from "react" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { message } from "antd" -import { - createAsset, - updateAsset, - deleteAsset, - uploadAssetDirect, - getAssetLibraries, - type AssetLibraryItem, -} from "@/api/assets" -import { tagAsset, untagAsset } from "@/api/tags" -import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../types" -import { getAudioDuration } from "../../utils/audio" +import { useState } from "react" +import { type VoiceMaterial } from "../../../types" +import { type AssetLibraryItem } from "@/api/assets" +import { useVoiceUpload } from "./actions/useVoiceUpload" +import { useVoiceEdit } from "./actions/useVoiceEdit" +import { useVoiceDelete } from "./actions/useVoiceDelete" interface UseVoiceMaterialActionsOptions { voiceLibrary?: { id: string; kind: string } @@ -21,184 +13,56 @@ interface UseVoiceMaterialActionsOptions { /** * 配音素材操作 Hook - * 封装上传、编辑、删除等变更操作及相关 UI 状态 + * 组合上传、编辑、删除三个子 Hook,统一管理弹窗状态 */ export function useVoiceMaterialActions({ voiceLibrary, materials, createLibMutation, }: UseVoiceMaterialActionsOptions) { - const queryClient = useQueryClient() - - // ── 弹窗状态 ────────────────────────────────────────────── + // ── 弹窗状态 ── const [uploadOpen, setUploadOpen] = useState(false) const [editingMaterial, setEditingMaterial] = useState(null) - // ── 上传进度 ────────────────────────────────────────────── - const [uploadProgress, setUploadProgress] = useState(null) + // ── 子领域 Hooks ── + const { uploadProgress, uploadMutation } = useVoiceUpload({ voiceLibrary, createLibMutation }) + const { editMutation } = useVoiceEdit({ materials }) + const { handleDelete } = useVoiceDelete({ materials }) - // ── 上传 mutation ───────────────────────────────────────── - const uploadMutation = useMutation({ - mutationFn: async (data: { - file: File - name: string - gender: VoiceGender - description: string - tagIds: string[] - }) => { - setUploadProgress(0) - try { - // 1. 获取或等待 voice library - let lib = voiceLibrary - if (!lib) { - if (createLibMutation.isPending) { - await createLibMutation.mutateAsync() - } - const libs = await queryClient.fetchQuery({ - queryKey: ["asset-libraries"], - queryFn: getAssetLibraries, - }) - lib = libs.find((l: AssetLibraryItem) => l.kind === "voice") - if (!lib) throw new Error("无法创建配音库") - } + /* ── 操作 handlers(关联弹窗状态) ── */ - // 2. 上传文件(带进度) - const { storage_key } = await uploadAssetDirect({ - file: data.file, - library_id: lib.id, - onProgress: (p) => setUploadProgress(p), - }) - - // 3. 获取音频时长 - const duration = await getAudioDuration(data.file) - - // 4. 创建素材记录 - const asset = await createAsset({ - library_id: lib.id, - name: data.name, - storage_key, - mime_type: data.file.type || "audio/mpeg", - metadata: buildMetadata({ - gender: data.gender, - description: data.description, - duration, - }), - }) - - // 5. 打标签(标签走独立 API) - if (data.tagIds.length > 0) { - await tagAsset(asset.id, data.tagIds) - } - } finally { - setUploadProgress(null) - } - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) - queryClient.invalidateQueries({ queryKey: ["tags"] }) - }, - onError: (err: Error) => { - message.error(err.message || "上传失败,请重试") - }, - }) - - // ── 编辑 mutation ───────────────────────────────────────── - const editMutation = useMutation({ - mutationFn: async (data: { - id: string - name: string - gender: VoiceGender - description: string - tagIds: string[] - }) => { - // 1. 更新基础信息 - await updateAsset(data.id, { - name: data.name, - metadata: buildMetadata({ - gender: data.gender, - description: data.description, - }), - }) - - // 2. 对比标签差异,调用 tag/untag API - const currentAsset = materials.find((m) => m.id === data.id) - const oldTagIds = currentAsset?.tagIds ?? [] - const newTagIds = data.tagIds - - const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id)) - const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id)) - - if (toAdd.length > 0) { - await tagAsset(data.id, toAdd) - } - for (const tagId of toRemove) { - await untagAsset(data.id, tagId) - } - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) - queryClient.invalidateQueries({ queryKey: ["tags"] }) - }, - }) - - // ── 删除 mutation ───────────────────────────────────────── - const deleteMutation = useMutation({ - mutationFn: (assetId: string) => deleteAsset(assetId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) - }, - }) - - /* ── 数据操作 handlers ──────────────────────────────────── */ - - const handleUpload = useCallback( - (data: Omit & { file?: File }) => { - if (!data.file) return - uploadMutation.mutate( - { - file: data.file, - name: data.name, - gender: data.gender, - description: data.description, - tagIds: data.tagIds, - }, - { - onSuccess: () => { - setUploadOpen(false) - }, - }, - ) - }, - [uploadMutation], - ) - - const handleEdit = useCallback( - (data: Omit & { file?: File }) => { - if (!editingMaterial) return - editMutation.mutate({ - id: editingMaterial.id, + const handleUpload = (data: Omit & { file?: File }) => { + if (!data.file) return + uploadMutation.mutate( + { + file: data.file, name: data.name, gender: data.gender, description: data.description, tagIds: data.tagIds, - }) - setEditingMaterial(null) - }, - [editingMaterial, editMutation], - ) + }, + { + onSuccess: () => { + setUploadOpen(false) + }, + }, + ) + } - const handleDelete = useCallback( - (id: string, onBeforeDelete?: () => void) => { - const material = materials.find((m) => m.id === id) - if (!material) return - if (onBeforeDelete) onBeforeDelete() - deleteMutation.mutate(id) - }, - [materials, deleteMutation], - ) + const handleEdit = (data: Omit & { file?: File }) => { + if (!editingMaterial) return + editMutation.mutate({ + id: editingMaterial.id, + name: data.name, + gender: data.gender, + description: data.description, + tagIds: data.tagIds, + }) + setEditingMaterial(null) + } return { - // 上传 & 编辑状态 + // 上传 & 编辑 loading 状态 uploadProgress, isUploading: uploadMutation.isPending, isEditing: editMutation.isPending, diff --git a/apps/web/src/test/pages/voice-materials/smoke.test.tsx b/apps/web/src/test/pages/voice-materials/smoke.test.tsx old mode 100644 new mode 100755 index cafe25e2b..fff55db19 --- a/apps/web/src/test/pages/voice-materials/smoke.test.tsx +++ b/apps/web/src/test/pages/voice-materials/smoke.test.tsx @@ -33,6 +33,9 @@ describe("VoiceMaterialLibrary module smoke test", () => { // Hooks import "@/pages/voice-materials/hooks/useVoiceMaterials" import "@/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialActions" +import "@/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload" +import "@/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceEdit" +import "@/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceDelete" import "@/pages/voice-materials/hooks/useTtsSynthesize" import "@/pages/voice-materials/hooks/useAudioPlayer" import "@/pages/voice-materials/hooks/useBatchOperations"