Przeglądaj źródła

feat: 添加上传声音克隆功能

王晓东 3 dni temu
rodzic
commit
fb5ce2c4b3

+ 1 - 1
src/api/index.ts

@@ -10,7 +10,7 @@ const MAX_RETRY_COUNT = 3 // 最大重试次数
 const RETRY_DELAY = 1000 // 重试延迟时间(毫秒)
 
 export const BASE_URL = (import.meta.env.DEV && import.meta.env.VITE_OPEN_PROXY) ? '/proxy/' : import.meta.env.VITE_APP_API_BASEURL
-
+export const BASE_CDN_URL = (import.meta.env.DEV && import.meta.env.VITE_OPEN_PROXY) ? '/proxy/' : import.meta.env.VITE_APP_CDN_BASEURL
 // 扩展 AxiosRequestConfig 类型
 declare module 'axios' {
   export interface AxiosRequestConfig {

+ 5 - 0
src/api/modules/anycallService.ts

@@ -90,3 +90,8 @@ export function fetchtNationalityList(){
 }
 
 
+// 克隆声音
+
+export function cloneVoice(params: {name: string, gender?: number, audioUrl: string}){
+  return request(`/anycall/cloneVoice`, params)
+}

+ 45 - 0
src/components/Uploader/AudioFileUploader.vue

@@ -0,0 +1,45 @@
+<script setup lang="ts">
+import type { UploadFile, UploadFiles } from 'element-plus'
+import { BASE_CDN_URL } from '@/api/index'
+import FileUploader from './FileUploader.vue'
+
+
+const props = withDefaults(defineProps<{
+  modelValue?: UploadFiles | null | undefined
+}>(), {
+
+})
+
+
+const emit = defineEmits<{
+  // 上传成功时 emit 事件
+  'onSuccess': [response: any, file: UploadFile, fileList: UploadFiles]
+  // 更新 v-model: 必须命名为 'update:modelValue' 以支持 v-model
+  'update:modelValue': [value: UploadFiles | null]
+}>()
+
+function handleUploadSuccess(response: any, uploadFile: UploadFile, uploadFiles: UploadFiles) {
+
+  emit('update:modelValue', uploadFiles)
+
+  // 7. 触发成功事件
+  emit('onSuccess', response, uploadFile, uploadFiles)
+
+}
+
+
+</script>
+
+<template>
+  <FileUploader
+    v-bind="$attrs"
+    :action="`${BASE_CDN_URL}/audio/upload2Wav`"
+    :ext="['wav', 'mp3']"
+    :limit="1"
+    v-model:file-list="props.modelValue"
+    @on-success="handleUploadSuccess"
+    :autoUpload="true"
+  >
+    <slot></slot>
+  </FileUploader>
+</template>

+ 136 - 0
src/components/Uploader/FileImportUploader.vue

@@ -0,0 +1,136 @@
+<script setup lang="ts">
+import { ref, watch } from 'vue'
+import { Plus, Upload } from '@element-plus/icons-vue'
+import { ElButton, ElUpload, } from 'element-plus'
+import type {UploadProgressEvent, UploadFile, UploadFiles } from 'element-plus'
+import { toast } from 'vue-sonner'
+import { BASE_URL } from '@/api/index'
+
+
+
+
+const props = withDefaults(defineProps<{
+  modelValue?: UploadFile | null | undefined
+  action?: string
+  method?: string
+  headers?: Headers | Record<string, any>
+  data?: Record<string, any>
+
+  name?: string
+  afterUpload?: (response: any) => string | Promise<string>
+  multiple?: boolean
+  ext?: string[]
+  max?: number
+  width?: number
+  height?: number
+  dimension?: {
+    width: number
+    height: number
+  }
+  size?: number
+  hideTips?: boolean
+  disabled?: boolean
+}>(), {
+  method: 'post',
+  headers: () => {
+    const userStore = useUserStore()
+    if (userStore.isLogin) {
+        return {
+          accessToken: userStore.token
+        }
+    }
+    return {}
+  },
+  data: () => ({}),
+  name: 'file',
+  multiple: false,
+  ext: () => [],
+  max: 1,
+  width: 100,
+  height: 100,
+  size: 5 * 1024 * 1024,
+  hideTips: false,
+  disabled: false,
+})
+
+
+const emit = defineEmits<{
+  // 上传成功时 emit 事件
+  'success': [response: any, file: UploadFile, fileList: UploadFiles]
+  // 更新 v-model: 必须命名为 'update:modelValue' 以支持 v-model
+  'update:modelValue': [value: UploadFile | null]
+}>()
+
+
+const internalFileList = ref<UploadFiles>(props.modelValue ? [props.modelValue] : [])
+
+watch(
+  () => props.modelValue,
+  (newVal) => {
+    // 如果父组件的 modelValue 变为 null/undefined,清空内部列表
+    if (!newVal) {
+      internalFileList.value = []
+    } else {
+      // 如果有新值,更新(避免重复添加)
+      if (internalFileList.value[0]?.uid !== newVal.uid) {
+        internalFileList.value = [newVal]
+      }
+    }
+  }
+)
+
+const loading	 = ref(false)
+
+
+
+function handleUploadSuccess(response: any, uploadFile: UploadFile, uploadFiles: UploadFiles) {
+  console.log('【上传成功】响应:', response)
+  console.log('【上传成功】文件对象:', uploadFile) // 这是最新的文件对象 (包含 url 等)
+  loading.value = false
+  emit('update:modelValue', uploadFile)
+
+  // 7. 触发成功事件
+  emit('success', response, uploadFile, uploadFiles)
+
+}
+
+// 8. 上传失败的回调
+function handleUploadError(error: Error, uploadFile: UploadFile, uploadFiles: UploadFiles) {
+  console.error('【上传失败】:', error, uploadFile)
+  emit('update:modelValue', null) // 失败时也通知父组件
+  toast.error('文件上传失败')
+}
+
+function handleProgress (evt: UploadProgressEvent, uploadFile: UploadFile, uploadFiles: UploadFiles) {
+  loading.value = true
+  console.log(evt, uploadFile, uploadFiles)
+}
+
+// 添加文件选择变化的处理
+function handleFileChange(file: UploadFile, fileList: UploadFiles) {
+  console.log(file, fileList)
+  // 当有新文件被选择时,清空现有列表,允许新文件上传
+  if (file.status === 'success') {
+    internalFileList.value = [];
+  }
+}
+
+
+</script>
+
+<template>
+  <ElUpload
+    v-model:file-list="internalFileList"
+    :action="`${BASE_URL}/anycall/admin/import`"
+    :show-file-list="false"
+    :limit="1"
+    :headers="props.headers"
+    @success="handleUploadSuccess"
+    @progress="handleProgress"
+    @change="handleFileChange"
+    @error="handleUploadError"
+    :auto-upload="true"
+  >
+    <ElButton :loading="loading" type="primary" :icon="Upload">Import</ElButton>
+  </ElUpload>
+</template>

+ 20 - 14
src/components/Uploader/FileUploader.vue

@@ -6,11 +6,14 @@ import type {UploadProgressEvent, UploadFile, UploadFiles } from 'element-plus'
 import { toast } from 'vue-sonner'
 import { BASE_URL } from '@/api/index'
 
+import { EXTENSION_TO_MIME_TYPE_MAP } from '@/constants/index'
+import type { TFileExtension } from '@/constants/index'
+
 
 
 
 const props = withDefaults(defineProps<{
-  modelValue?: UploadFile | null | undefined
+  modelValue?: UploadFiles | null
   action?: string
   method?: string
   headers?: Headers | Record<string, any>
@@ -19,7 +22,7 @@ const props = withDefaults(defineProps<{
   name?: string
   afterUpload?: (response: any) => string | Promise<string>
   multiple?: boolean
-  ext?: string[]
+  ext?: TFileExtension[]
   max?: number
   width?: number
   height?: number
@@ -56,13 +59,18 @@ const props = withDefaults(defineProps<{
 
 const emit = defineEmits<{
   // 上传成功时 emit 事件
-  'success': [response: any, file: UploadFile, fileList: UploadFiles]
+  'onSuccess': [response: any, file: UploadFile, fileList: UploadFiles]
   // 更新 v-model: 必须命名为 'update:modelValue' 以支持 v-model
   'update:modelValue': [value: UploadFile | null]
 }>()
 
 
-const internalFileList = ref<UploadFiles>(props.modelValue ? [props.modelValue] : [])
+const internalFileList = ref<UploadFiles>(props.modelValue || [])
+
+
+const loading	 = ref(false)
+
+const acceptTypes = computed(() => props.ext.map(e => EXTENSION_TO_MIME_TYPE_MAP[e]).join(','));
 
 watch(
   () => props.modelValue,
@@ -71,15 +79,12 @@ watch(
     if (!newVal) {
       internalFileList.value = []
     } else {
-      // 如果有新值,更新(避免重复添加)
-      if (internalFileList.value[0]?.uid !== newVal.uid) {
-        internalFileList.value = [newVal]
-      }
+      internalFileList.value = newVal
     }
   }
 )
 
-const loading	 = ref(false)
+
 
 
 
@@ -90,7 +95,7 @@ function handleUploadSuccess(response: any, uploadFile: UploadFile, uploadFiles:
   emit('update:modelValue', uploadFile)
 
   // 7. 触发成功事件
-  emit('success', response, uploadFile, uploadFiles)
+  emit('onSuccess', response, uploadFile, uploadFiles)
 
 }
 
@@ -120,17 +125,18 @@ function handleFileChange(file: UploadFile, fileList: UploadFiles) {
 
 <template>
   <ElUpload
+    v-bind="$attrs"
     v-model:file-list="internalFileList"
-    :action="`${BASE_URL}/anycall/admin/import`"
-    :show-file-list="false"
+    :action="props.action || `${BASE_URL}/cmn/upload`"
     :limit="1"
     :headers="props.headers"
     @success="handleUploadSuccess"
     @progress="handleProgress"
     @change="handleFileChange"
     @error="handleUploadError"
-    :auto-upload="true"
+    :accept="acceptTypes"
   >
-    <ElButton :loading="loading" type="primary" :icon="Upload">Import</ElButton>
+      <slot v-if="$slots.default" />
+      <ElButton v-else :loading="loading" type="primary" :icon="Upload">Import</ElButton>
   </ElUpload>
 </template>

+ 27 - 0
src/constants/index.ts

@@ -0,0 +1,27 @@
+export const FILE_EXTENSIONS = [
+  'doc', 'docx',
+  'xls', 'xlsx', 'txt',
+  'jpg', 'jpeg', 'png', 'mp4', 'pdf', 'md'
+];
+
+export const FILE_EXTENSIONS_MEDIA = [
+  'jpg', 'jpeg', 'png', 'mp4',
+]
+
+export const EXTENSION_TO_MIME_TYPE_MAP = {
+  mp3: 'audio/mpeg',
+  wav: 'audio/wav',
+  pdf: 'application/pdf',
+  md: 'text/markdown',
+  txt: 'text/plain',
+  xls: 'application/vnd.ms-excel',
+  xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+  doc: 'application/msword',
+  docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+  jpg: 'image/jpeg',
+  jpeg: 'image/jpeg',
+  png: 'image/png',
+  mp4: 'video/mp4',
+};
+
+export type TFileExtension = keyof typeof EXTENSION_TO_MIME_TYPE_MAP

+ 2 - 2
src/views/role-management/index.vue

@@ -7,7 +7,7 @@ defineOptions({
 import { Plus, Upload } from '@element-plus/icons-vue'
 import { ElButton, ElDialog, ElEmpty, ElInput, ElMessage, ElOption, ElPagination, ElSelect, ElTable, ElTableColumn, ElTag } from 'element-plus'
 import type { UploadFile, UploadFiles } from 'element-plus'
-import FileUploader from '@/components/Uploader/FileUploader.vue'
+import FileImportUploader from '@/components/Uploader/FileImportUploader.vue'
 import EditForm from './components/EditForm.vue'
 import EditLLMForm from './components/EditLLMForm.vue'
 import EditCallingsForm from './components/EditCallingsForm.vue'
@@ -154,7 +154,7 @@ onMounted(async () => {
     <FaPageMain class="flex-1 overflow-auto" main-class="flex-1 flex flex-col overflow-auto">
       <div class="pb-4">
         <ElSpace>
-          <FileUploader @success="handleUploadSuccess" ></FileUploader>
+          <FileImportUploader @success="handleUploadSuccess" ></FileImportUploader>
           <ElButton type="primary" :icon="Plus" @click="handleCreate">Create Role</ElButton>
           {{ importedFile?.name }}
         </ElSpace>

+ 83 - 0
src/views/voice-management/components/SearchForm.vue

@@ -0,0 +1,83 @@
+<script setup lang="ts">
+import { Refresh, Search } from '@element-plus/icons-vue'
+import { ElButton, ElCol, ElForm, ElFormItem, ElInput, ElOption, ElRow, ElSelect } from 'element-plus'
+import { ref } from 'vue'
+type TSearchParams = {
+  name: string,
+  type: number
+}
+interface Props {
+  modelValue: TSearchParams
+  loading?: boolean
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: TSearchParams): void
+  (e: 'search'): void
+  (e: 'reset'): void
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  loading: false,
+})
+
+const emit = defineEmits<Emits>()
+
+
+const formRef = ref()
+
+const searchParams = computed({
+  get: () => props.modelValue,
+  set: value => emit('update:modelValue', value),
+})
+
+/**
+ * 搜索
+ */
+function handleSearch() {
+  emit('search')
+}
+
+/**
+ * 重置
+ */
+function handleReset() {
+  formRef.value?.resetFields()
+  emit('reset')
+}
+
+function handleClear() {
+  // 清空后自动触发搜索
+  handleSearch()
+}
+</script>
+
+<template>
+  <FaSearchBar :showToggle="false">
+    <ElForm ref="formRef" :model="searchParams" inline label-width="80px">
+      <ElRow :gutter="16">
+        <!-- 基础搜索 -->
+        <ElCol :span="24">
+          <ElFormItem label="名称" prop="name" label-width="120">
+            <ElInput v-model="searchParams.name" placeholder="请输入" clearable @clear="handleClear" />
+          </ElFormItem>
+          <ElFormItem label="类型" prop="type" label-width="120">
+            <ElSelect v-model="searchParams.type" style="width: 120px">
+              <ElOption :value="1" label="系统">系统</ElOption>
+              <ElOption :value="2" label="系统克隆">系统克隆</ElOption>
+              <ElOption :value="3" label="系统克隆">用户</ElOption>
+            </ElSelect>
+          </ElFormItem>
+          <ElFormItem>
+            <ElButton type="primary" :icon="Search" :loading="loading" @click="handleSearch">
+              查询
+            </ElButton>
+            <ElButton :icon="Refresh" @click="handleReset">
+              重置
+            </ElButton>
+          </ElFormItem>
+        </ElCol>
+      </ElRow>
+    </ElForm>
+  </FaSearchBar>
+</template>

+ 50 - 6
src/views/voice-management/index.vue

@@ -5,23 +5,32 @@ defineOptions({
 })
 import { Plus } from '@element-plus/icons-vue'
 import { ElButton, ElDialog, ElEmpty, ElInput, ElOption, ElPagination, ElSelect, ElTable, ElTableColumn, ElTag } from 'element-plus'
-// import SearchForm from './components/SearchForm.vue'
-
+import SearchForm from './components/SearchForm.vue'
+import AudioFileUploader from '@/components/Uploader/AudioFileUploader.vue'
 import type { TVoice } from '@/types/voice'
-import { voiceList } from '@/api/modules/anycallService'
+import { toast } from 'vue-sonner'
+import { voiceList, cloneVoice } from '@/api/modules/anycallService'
 import { formatDateGeneral } from '@/utils'
 
 const tableRef = ref()
 const loading = ref(false)
+const cloneLoading = ref(false)
 const router = useRouter()
 const route = useRoute()
 
 // 搜索参数
-const searchParams = ref({
+const searchParams = ref<{name: string, type: number}>({
   name: '',
+  type: 1
 })
 const dataList = ref<TVoice[]>([]);
 
+const currentAudio = ref<{
+    src: string;
+    srcName: string;
+    duration: number;
+}|undefined>()
+
 // 从URL获取初始分页参数
 const getInitialPagination = () => {
   const page = Number(route.query.page) || 1
@@ -60,7 +69,7 @@ async function fetchData() {
   loading.value = true
   const res = await voiceList({
     gender: 1,
-    system: true,
+    system: searchParams.value.type === 1 ? true : false,
     name: searchParams.value.name,
     page: pagination.value.page,
     size: pagination.value.size,
@@ -94,6 +103,7 @@ function handleSearch () {
 function handleReset () {
   searchParams.value = {
     name: '',
+    type: 1,
   }
   pagination.value.page = 1 // 重置时重置到第一页
   updateUrlParams(1, pagination.value.size)
@@ -118,6 +128,30 @@ watch(
   { deep: true }
 )
 
+const handleUploadSuccess = (res: { code: number, data: { src: string, srcName: string, duration: number } }, file: any) => {
+  console.log(res, file)
+  if (res.code === 0) {
+
+    currentAudio.value = res.data
+    handleClone(res.data)
+
+  }
+}
+
+const handleClone = async (res:  { src: string, srcName: string, duration: number }) => {
+  cloneLoading.value = true
+  const {code, data} =  await cloneVoice({
+    name: res.srcName,
+    audioUrl: res.src,
+    gender: 1
+  })
+  cloneLoading.value = false
+  console.log(code, data)
+  if(code === 0){
+    toast.success('克隆成功')
+  }
+}
+
 onMounted(async () => {
   await fetchData()
 })
@@ -127,8 +161,18 @@ onMounted(async () => {
 
 <template>
   <div class="absolute-container">
-    <FaPageHeader title="Voice Management" />
+    <FaPageMain class="mb-0">
+      <ElCard shadow="never">
+        <SearchForm v-model="searchParams" @search="handleSearch" @reset="handleReset"></SearchForm>
+      </ElCard>
+    </FaPageMain>
     <FaPageMain class="flex-1 overflow-auto" main-class="flex-1 flex flex-col overflow-auto">
+      <div class="pb-4">
+        <ElSpace>
+          <AudioFileUploader :disabled="cloneLoading" @on-success="handleUploadSuccess"><ElButton type="primary" :icon="Plus">克隆声音</ElButton></AudioFileUploader>
+          <!-- <ElButton type="primary" :icon="Plus" @click="handleClone">克隆声音</ElButton> -->
+        </ElSpace>
+      </div>
       <ElTable
         ref="tableRef" :data="dataList" stripe highlight-current-row border height="100%"
       >