|
@@ -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>
|