瀏覽代碼

add 上传声纹

ct 23 小時之前
父節點
當前提交
91dd377c9d

+ 9 - 0
src/api/company/companyUser.js

@@ -392,3 +392,12 @@ export function analyseCompanyUserInfo(userId) {
     method: 'get'
   })
 }
+
+// 上传声纹
+export function addVoicePrintUrl(data) {
+  return request({
+    url: '/company/user/addVoicePrintUrl',
+    method: 'post',
+    data: data
+  })
+}

+ 354 - 0
src/views/company/companyUser/components/VoiceCollectDialog.vue

@@ -0,0 +1,354 @@
+<template>
+  <el-dialog
+    :visible.sync="dialogVisible"
+    width="420px"
+    append-to-body
+    :show-close="true"
+    custom-class="voice-collect-dialog"
+    @close="handleClose"
+  >
+    <div class="voice-collect">
+      <p class="voice-collect-title">请朗读以下文字</p>
+      <div class="voice-collect-text">{{ readText }}</div>
+      <ol class="voice-collect-tips">
+        <li>选择安静的录音环境,可在房间或车内录音。</li>
+        <li>保持20cm距离,避免手机太远录音不清晰。</li>
+        <li>使用普通话朗读,语速适中,吐字清晰。</li>
+      </ol>
+      <div class="voice-record-area">
+        <div
+          class="record-btn"
+          :class="{ recording: isRecording, 'has-audio': audioBlob && !isRecording }"
+          @click="toggleRecording"
+        >
+          <i :class="isRecording ? 'el-icon-video-pause' : 'el-icon-microphone'"></i>
+        </div>
+        <p class="record-label">{{ recordLabel }}</p>
+        <p v-if="isRecording" class="record-time">{{ recordingTime }}s</p>
+      </div>
+      <div v-if="audioUrl && !isRecording" class="voice-preview">
+        <audio :src="audioUrl" controls></audio>
+        <el-button type="text" icon="el-icon-refresh" @click="resetRecording">重新录制</el-button>
+      </div>
+    </div>
+    <div slot="footer" class="dialog-footer">
+      <el-button @click="handleClose">取 消</el-button>
+      <el-button
+        type="primary"
+        :disabled="!audioBlob || isRecording"
+        :loading="uploading"
+        @click="handleSubmit"
+      >上 传</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import axios from 'axios'
+import { addVoicePrintUrl } from '@/api/company/companyUser'
+
+const DEFAULT_READ_TEXT = '在这片神奇的森林里,小鸟轻快地唱着歌,仿佛在诉说着春天的故事。'
+
+export default {
+  name: 'VoiceCollectDialog',
+  data() {
+    return {
+      dialogVisible: false,
+      currentUser: null,
+      readText: DEFAULT_READ_TEXT,
+      isRecording: false,
+      mediaRecorder: null,
+      mediaStream: null,
+      audioChunks: [],
+      audioBlob: null,
+      audioUrl: null,
+      recordingTime: 0,
+      recordingTimer: null,
+      uploading: false,
+      cachedVoiceUrl: null
+    }
+  },
+  computed: {
+    recordLabel() {
+      if (this.isRecording) {
+        return '点击结束'
+      }
+      if (this.audioBlob) {
+        return '重新录制'
+      }
+      return '点击录制'
+    }
+  },
+  beforeDestroy() {
+    this.cleanupRecording()
+  },
+  methods: {
+    open(row, readText) {
+      this.currentUser = row || null
+      this.readText = readText || DEFAULT_READ_TEXT
+      this.resetRecording()
+      this.dialogVisible = true
+    },
+    async toggleRecording() {
+      if (this.isRecording) {
+        this.stopRecording()
+        return
+      }
+      if (this.audioBlob) {
+        this.resetRecording()
+      }
+      await this.startRecording()
+    },
+    async startRecording() {
+      if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+        this.$message.error('当前浏览器不支持录音,请使用 Chrome 等现代浏览器')
+        return
+      }
+      try {
+        this.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
+        this.mediaRecorder = new MediaRecorder(this.mediaStream)
+        this.audioChunks = []
+
+        this.mediaRecorder.ondataavailable = (event) => {
+          if (event.data && event.data.size > 0) {
+            this.audioChunks.push(event.data)
+          }
+        }
+
+        this.mediaRecorder.onstop = () => {
+          if (this.audioChunks.length) {
+            this.audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' })
+            this.cachedVoiceUrl = null
+            this.revokeAudioUrl()
+            this.audioUrl = URL.createObjectURL(this.audioBlob)
+          }
+          this.stopMediaStream()
+        }
+
+        this.mediaRecorder.start()
+        this.isRecording = true
+        this.recordingTime = 0
+        this.recordingTimer = setInterval(() => {
+          this.recordingTime++
+        }, 1000)
+      } catch (error) {
+        this.$message.error('无法访问麦克风,请检查浏览器权限设置')
+        console.error('录音错误:', error)
+        this.cleanupRecording()
+      }
+    },
+    stopRecording() {
+      if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
+        this.mediaRecorder.stop()
+      }
+      this.isRecording = false
+      if (this.recordingTimer) {
+        clearInterval(this.recordingTimer)
+        this.recordingTimer = null
+      }
+    },
+    stopMediaStream() {
+      if (this.mediaStream) {
+        this.mediaStream.getTracks().forEach(track => track.stop())
+        this.mediaStream = null
+      }
+    },
+    revokeAudioUrl() {
+      if (this.audioUrl) {
+        URL.revokeObjectURL(this.audioUrl)
+        this.audioUrl = null
+      }
+    },
+    resetRecording() {
+      this.stopRecording()
+      this.audioBlob = null
+      this.cachedVoiceUrl = null
+      this.revokeAudioUrl()
+      this.recordingTime = 0
+      this.audioChunks = []
+      this.mediaRecorder = null
+      this.stopMediaStream()
+    },
+    cleanupRecording() {
+      this.resetRecording()
+    },
+    handleClose() {
+      this.dialogVisible = false
+      this.cleanupRecording()
+      this.currentUser = null
+    },
+    async handleSubmit() {
+      if (!this.audioBlob) {
+        this.$message.warning('请先录制语音')
+        return
+      }
+      if (!this.currentUser || !this.currentUser.userId) {
+        this.$message.warning('用户信息不存在')
+        return
+      }
+
+      this.uploading = true
+      try {
+        let voiceUrl = this.cachedVoiceUrl
+        if (!voiceUrl) {
+          const formData = new FormData()
+          const fileName = `voice_${this.currentUser.userId}_${Date.now()}.webm`
+          formData.append('file', this.audioBlob, fileName)
+
+          const uploadUrl = process.env.VUE_APP_BASE_API + '/common/uploadOSS'
+          const response = await axios.post(uploadUrl, formData)
+
+          voiceUrl = response.data && (response.data.url || (response.data.data && response.data.data.url))
+          if (!voiceUrl) {
+            throw new Error((response.data && (response.data.msg || response.data.message)) || '上传失败,未返回语音地址')
+          }
+          this.cachedVoiceUrl = voiceUrl
+        }
+
+        await addVoicePrintUrl({
+          voicePrintUrl: voiceUrl,
+          companyUserId: this.currentUser.userId
+        })
+
+        this.$message.success('声音采集成功')
+        this.$emit('success', {
+          user: this.currentUser,
+          voiceUrl,
+          duration: this.recordingTime
+        })
+        this.handleClose()
+      } catch (error) {
+        console.error('声音采集上传失败:', error)
+        this.$message.error(error.message || '声音采集上传失败')
+      } finally {
+        this.uploading = false
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss">
+.voice-collect-dialog {
+  .el-dialog__header {
+    padding: 16px 16px 0;
+  }
+  .el-dialog__body {
+    padding: 8px 24px 16px;
+    background: #f7f8fa;
+  }
+  .el-dialog__footer {
+    background: #f7f8fa;
+    padding-top: 0;
+  }
+}
+</style>
+
+<style lang="scss" scoped>
+.voice-collect {
+  text-align: center;
+}
+
+.voice-collect-title {
+  margin: 0 0 16px;
+  font-size: 14px;
+  color: #909399;
+}
+
+.voice-collect-text {
+  padding: 20px 16px;
+  background: #fff;
+  border-radius: 8px;
+  font-size: 15px;
+  line-height: 1.8;
+  color: #303133;
+  text-align: left;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
+}
+
+.voice-collect-tips {
+  margin: 16px 0 24px;
+  padding-left: 18px;
+  text-align: left;
+  font-size: 12px;
+  line-height: 1.8;
+  color: #909399;
+
+  li {
+    margin-bottom: 4px;
+  }
+}
+
+.voice-record-area {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.record-btn {
+  width: 72px;
+  height: 72px;
+  border-radius: 50%;
+  background: #ff6a00;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  box-shadow: 0 4px 12px rgba(255, 106, 0, 0.35);
+
+  i {
+    font-size: 32px;
+    color: #fff;
+  }
+
+  &:hover {
+    background: #ff8124;
+  }
+
+  &.recording {
+    animation: pulse 1.2s infinite;
+    background: #f56c6c;
+    box-shadow: 0 4px 12px rgba(245, 108, 108, 0.4);
+  }
+
+  &.has-audio {
+    background: #ff6a00;
+  }
+}
+
+@keyframes pulse {
+  0% {
+    transform: scale(1);
+  }
+  50% {
+    transform: scale(1.06);
+  }
+  100% {
+    transform: scale(1);
+  }
+}
+
+.record-label {
+  margin: 12px 0 0;
+  font-size: 14px;
+  color: #606266;
+}
+
+.record-time {
+  margin: 6px 0 0;
+  font-size: 13px;
+  color: #f56c6c;
+}
+
+.voice-preview {
+  margin-top: 20px;
+  padding-top: 16px;
+  border-top: 1px dashed #e4e7ed;
+
+  audio {
+    width: 100%;
+    margin-bottom: 8px;
+  }
+}
+</style>

+ 10 - 1
src/views/company/companyUser/index.vue

@@ -310,6 +310,7 @@
 
               <el-button size="mini" type="text" icon="el-icon-edit" @click="checkBindSipCallUser(scope.row)" v-if="scope.row.aiSipCallUserId==null">绑定sip角色</el-button>
               <el-button size="mini" type="text" icon="el-icon-search" @click="checkChangeSipCallUser(scope.row)" v-if="scope.row.aiSipCallUserId">修改sip角色</el-button>
+              <el-button size="mini" type="text" icon="el-icon-s-cooperation" @click="handleVoiceCollect(scope.row)">声音采集</el-button>
 
               <el-button v-if="scope.row.userType !== '00'" size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['company:user:edit']">修改</el-button>
               <el-button v-if="scope.row.userType !== '00'" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['company:user:remove']">删除</el-button>
@@ -760,6 +761,7 @@
 
 
     <ai-sip-call-user ref="aiSipCallUser" v-show="false" @refreshParentData="getList" />
+    <voice-collect-dialog ref="voiceCollectDialog" @success="handleVoiceCollectSuccess" />
     <!-- 销售行为分析抽屉 -->
     <el-drawer
       title="销售行为分析"
@@ -892,11 +894,12 @@ import axios from "axios";
 import {addCodeUrl} from "../../../api/company/companyUser";
 import selectDoctor from "@/views/qw/user/selectDoctor.vue";
 import AiSipCallUser from "../../aiSipCall/aiSipCallUser.vue";
+import VoiceCollectDialog from "./components/VoiceCollectDialog.vue";
 import {bindCidServer,unbindCidServer} from "@/api/company/companyAiWorkflowServer";
 
 export default {
   name: "User",
-  components: {selectDoctor, Treeselect ,selectUser,AiSipCallUser},
+  components: {selectDoctor, Treeselect ,selectUser, AiSipCallUser, VoiceCollectDialog},
   data() {
     return {
       doctor: {
@@ -2051,6 +2054,12 @@ export default {
     checkChangeSipCallUser(row){
       this.$refs.aiSipCallUser.handleUpdateById(row.aiSipCallUserId);
     },
+    handleVoiceCollect(row) {
+      this.$refs.voiceCollectDialog.open(row);
+    },
+    handleVoiceCollectSuccess() {
+      this.getList();
+    },
     /** 打开销售行为分析抽屉 */
     handleOpenSalesAnalysis(row) {
       this.drawerVisible = true;