mirror of
https://gitee.com/lijingbo-2021/open-anylink-web.git
synced 2025-12-30 11:02:25 +00:00
以文件方式上传音频
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
"protobufjs": "^7.4.0",
|
||||
"uuid": "^10.0.0",
|
||||
"vue": "^3.4.29",
|
||||
"vue-audio-visual": "^3.0.10",
|
||||
"vue-cropper": "^1.1.1",
|
||||
"vue-router": "^4.3.3"
|
||||
},
|
||||
|
||||
@@ -7,3 +7,7 @@ export const mtsUploadService = (obj) => {
|
||||
export const mtsImageService = (obj) => {
|
||||
return request.get('/mts/image', { params: obj })
|
||||
}
|
||||
|
||||
export const mtsAudioService = (obj) => {
|
||||
return request.get('/mts/audio', { params: obj })
|
||||
}
|
||||
|
||||
1
src/assets/svg/pause.svg
Normal file
1
src/assets/svg/pause.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1742561362047" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7934" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M682.666667 213.333333a42.666667 42.666667 0 0 1 42.666666 42.666667v512a42.666667 42.666667 0 0 1-85.333333 0V256a42.666667 42.666667 0 0 1 42.666667-42.666667zM341.333333 213.333333a42.666667 42.666667 0 0 1 42.666667 42.666667v512a42.666667 42.666667 0 0 1-85.333333 0V256a42.666667 42.666667 0 0 1 42.666666-42.666667z" fill="#000" p-id="7935"></path></svg>
|
||||
|
After Width: | Height: | Size: 698 B |
1
src/assets/svg/play.svg
Normal file
1
src/assets/svg/play.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1742561328036" class="svg-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6809" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M213.333333 65.386667a85.333333 85.333333 0 0 1 43.904 12.16L859.370667 438.826667a85.333333 85.333333 0 0 1 0 146.346666L257.237333 946.453333A85.333333 85.333333 0 0 1 128 873.28V150.72a85.333333 85.333333 0 0 1 85.333333-85.333333z m0 64a21.333333 21.333333 0 0 0-21.184 18.837333L192 150.72v722.56a21.333333 21.333333 0 0 0 30.101333 19.456l2.197334-1.152L826.453333 530.282667a21.333333 21.333333 0 0 0 2.048-35.178667l-2.048-1.386667L224.298667 132.416A21.333333 21.333333 0 0 0 213.333333 129.386667z" fill="#000" p-id="6810"></path></svg>
|
||||
|
After Width: | Height: | Size: 883 B |
41
src/stores/audio.js
Normal file
41
src/stores/audio.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { mtsAudioService } from '@/api/mts'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// audio的缓存数据,不持久化存储
|
||||
export const audioStore = defineStore('anylink-audio', () => {
|
||||
/**
|
||||
* {
|
||||
* objectId_01: {objectId: objectId_01, url: xxx},
|
||||
* objectId_02: {objectId: objectId_02, url: xxx},
|
||||
* }
|
||||
*/
|
||||
const audio = ref({})
|
||||
|
||||
/**
|
||||
* 在同一个session中的audio(id)集合
|
||||
*/
|
||||
const audioInSession = ref({})
|
||||
|
||||
const setAudio = (sessionId, obj) => {
|
||||
audio.value[obj.objectId] = obj
|
||||
if (!audioInSession.value[sessionId]) {
|
||||
audioInSession.value[sessionId] = []
|
||||
}
|
||||
audioInSession.value[sessionId].push(obj.objectId)
|
||||
}
|
||||
|
||||
const loadAudio = async (sessionId, objectId) => {
|
||||
if (!(objectId in audio.value)) {
|
||||
const res = await mtsAudioService({ objectId: objectId })
|
||||
setAudio(sessionId, res.data.data) // 缓存image数据
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
audio,
|
||||
audioInSession,
|
||||
setAudio,
|
||||
loadAudio
|
||||
}
|
||||
})
|
||||
@@ -13,3 +13,4 @@ export * from './search'
|
||||
export * from './userCard'
|
||||
export * from './groupCard'
|
||||
export * from './image'
|
||||
export * from './audio'
|
||||
|
||||
@@ -964,7 +964,7 @@ const onSendImage = ({ objectId }) => {
|
||||
}
|
||||
|
||||
const onSendAudio = ({ objectId }) => {
|
||||
console.log('onSendAudio: ', objectId)
|
||||
handleSendMessage(JSON.stringify({ type: msgContentType.AUDIO, value: `(${objectId})` }))
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { VideoPlay, VideoPause } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps(['audioUrl'])
|
||||
|
||||
const isPlaying = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const audioContext = ref(null)
|
||||
const audioBuffer = ref(null)
|
||||
const analyser = ref(null)
|
||||
const canvas = ref(null)
|
||||
const canvasCtx = ref(null)
|
||||
const animationFrame = ref(null)
|
||||
const audio = ref(null)
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time) => {
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 初始化音频上下文和分析器
|
||||
const initAudioContext = async () => {
|
||||
audioContext.value = new (window.AudioContext || window.webkitAudioContext)()
|
||||
analyser.value = audioContext.value.createAnalyser()
|
||||
analyser.value.fftSize = 256
|
||||
|
||||
try {
|
||||
const response = await fetch(props.audioUrl)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
audioBuffer.value = await audioContext.value.decodeAudioData(arrayBuffer)
|
||||
duration.value = audioBuffer.value.duration
|
||||
} catch (error) {
|
||||
console.error('音频加载失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制音频波形
|
||||
const drawWaveform = () => {
|
||||
if (!isPlaying.value || !canvas.value || !analyser.value) return
|
||||
|
||||
const bufferLength = analyser.value.frequencyBinCount
|
||||
const dataArray = new Uint8Array(bufferLength)
|
||||
analyser.value.getByteFrequencyData(dataArray)
|
||||
|
||||
canvasCtx.value.fillStyle = '#f5f5f5'
|
||||
canvasCtx.value.fillRect(0, 0, canvas.value.width, canvas.value.height)
|
||||
|
||||
const barWidth = (canvas.value.width / bufferLength) * 2.5
|
||||
let barHeight
|
||||
let x = 0
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
barHeight = (dataArray[i] / 255) * canvas.value.height
|
||||
canvasCtx.value.fillStyle = '#409eff'
|
||||
canvasCtx.value.fillRect(x, canvas.value.height - barHeight, barWidth, barHeight)
|
||||
x += barWidth + 1
|
||||
}
|
||||
|
||||
animationFrame.value = requestAnimationFrame(drawWaveform)
|
||||
}
|
||||
|
||||
// 播放/暂停音频
|
||||
const togglePlay = async () => {
|
||||
if (!audioContext.value) {
|
||||
await initAudioContext()
|
||||
}
|
||||
|
||||
if (isPlaying.value) {
|
||||
audioContext.value.suspend()
|
||||
cancelAnimationFrame(animationFrame.value)
|
||||
} else {
|
||||
audioContext.value.resume()
|
||||
drawWaveform()
|
||||
}
|
||||
isPlaying.value = !isPlaying.value
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
const updateProgress = (e) => {
|
||||
if (!audioBuffer.value) return
|
||||
const time = e.target.value
|
||||
currentTime.value = time
|
||||
audio.value.currentTime = time
|
||||
}
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onUnmounted(() => {
|
||||
if (audioContext.value) {
|
||||
audioContext.value.close()
|
||||
}
|
||||
if (animationFrame.value) {
|
||||
cancelAnimationFrame(animationFrame.value)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initAudioContext()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="audio-player">
|
||||
<el-button
|
||||
class="play-button"
|
||||
:icon="isPlaying ? VideoPause : VideoPlay"
|
||||
circle
|
||||
@click="togglePlay"
|
||||
/>
|
||||
<div class="progress-container">
|
||||
<canvas ref="canvas" class="waveform" width="300" height="40"></canvas>
|
||||
<el-slider
|
||||
v-model="currentTime"
|
||||
:max="duration"
|
||||
:step="0.1"
|
||||
@change="updateProgress"
|
||||
class="progress-slider"
|
||||
/>
|
||||
</div>
|
||||
<span class="time">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.audio-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.waveform {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.progress-slider {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.time {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
min-width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
:deep(.el-slider__runway) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.el-slider__bar) {
|
||||
background-color: rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
:deep(.el-slider__button-wrapper) {
|
||||
z-index: 3;
|
||||
}
|
||||
</style>
|
||||
124
src/views/message/components/AudioMsgBox.vue
Normal file
124
src/views/message/components/AudioMsgBox.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElIcon } from 'element-plus'
|
||||
import PlayIcon from '@/assets/svg/play.svg'
|
||||
import PauseIcon from '@/assets/svg/pause.svg'
|
||||
import { AVWaveform } from 'vue-audio-visual'
|
||||
|
||||
const props = defineProps(['audioUrl'])
|
||||
const emits = defineEmits(['load'])
|
||||
|
||||
const waveformRef = ref(null)
|
||||
const isPlaying = ref(false)
|
||||
const audioDuration = ref(null)
|
||||
|
||||
// 格式化时间
|
||||
const formatDuration = computed(() => {
|
||||
{
|
||||
const minutes = Math.floor(audioDuration.value / 60)
|
||||
const seconds = Math.floor(audioDuration.value % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
})
|
||||
|
||||
const playAudio = () => {
|
||||
const audioPlayer = waveformRef.value.querySelector('audio')
|
||||
if (audioPlayer) {
|
||||
audioPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
const pauseAudio = () => {
|
||||
const audioPlayer = waveformRef.value.querySelector('audio')
|
||||
if (audioPlayer) {
|
||||
audioPlayer.pause()
|
||||
}
|
||||
}
|
||||
|
||||
// 播放/暂停音频
|
||||
const togglePlay = async () => {
|
||||
if (isPlaying.value) {
|
||||
pauseAudio()
|
||||
} else {
|
||||
playAudio()
|
||||
}
|
||||
isPlaying.value = !isPlaying.value
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const audioPlayer = waveformRef.value.querySelector('audio')
|
||||
if (audioPlayer) {
|
||||
// 监听播放事件
|
||||
audioPlayer.addEventListener('play', () => {
|
||||
isPlaying.value = true
|
||||
})
|
||||
|
||||
// 监听暂停事件
|
||||
audioPlayer.addEventListener('pause', () => {
|
||||
isPlaying.value = false
|
||||
})
|
||||
|
||||
// 监听音频元数据加载完成事件
|
||||
audioPlayer.addEventListener('loadedmetadata', () => {
|
||||
audioDuration.value = audioPlayer.duration
|
||||
})
|
||||
}
|
||||
emits('load') //触发load事件
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="waveformRef" class="audio-player">
|
||||
<div class="play-button" @click="togglePlay">
|
||||
<el-icon v-if="isPlaying"><PauseIcon /></el-icon>
|
||||
<el-icon v-else><PlayIcon /></el-icon>
|
||||
</div>
|
||||
|
||||
<AVWaveform
|
||||
:src="props.audioUrl"
|
||||
:audio-controls="false"
|
||||
:playtime="false"
|
||||
:canv-width="150"
|
||||
:canv-height="40"
|
||||
:playtime-slider-color="`#409eff`"
|
||||
></AVWaveform>
|
||||
|
||||
<span class="time">{{ formatDuration }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.audio-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px 5px 10px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
|
||||
:deep(canvas) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #c6e2ff;
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,12 +3,19 @@ import { computed, onMounted, h, createApp, watch, nextTick, reactive } from 'vu
|
||||
import { ElImage } from 'element-plus'
|
||||
import { WarningFilled } from '@element-plus/icons-vue'
|
||||
import { MsgType } from '@/proto/msg'
|
||||
import { userStore, messageStore, groupStore, groupCardStore, imageStore } from '@/stores'
|
||||
import {
|
||||
userStore,
|
||||
messageStore,
|
||||
groupStore,
|
||||
groupCardStore,
|
||||
imageStore,
|
||||
audioStore
|
||||
} from '@/stores'
|
||||
import { messageSysShowTime, showTimeFormat, jsonParseSafe } from '@/js/utils/common'
|
||||
import UserAvatarIcon from '@/components/common/UserAvatarIcon.vue'
|
||||
import { emojis } from '@/js/utils/emojis'
|
||||
import { msgContentType, msgSendStatus } from '@/const/msgConst'
|
||||
import AudioMessage from '@/views/message/components/AudioMessage.vue'
|
||||
import AudioMsgBox from '@/views/message/components/AudioMsgBox.vue'
|
||||
|
||||
const props = defineProps([
|
||||
'sessionId',
|
||||
@@ -29,6 +36,7 @@ const messageData = messageStore()
|
||||
const groupData = groupStore()
|
||||
const groupCardData = groupCardStore()
|
||||
const imageData = imageStore()
|
||||
const audioData = audioStore()
|
||||
|
||||
onMounted(() => {
|
||||
rendering()
|
||||
@@ -71,7 +79,12 @@ const renderComponent = async (content) => {
|
||||
case msgContentType.TEXT:
|
||||
return renderText(value)
|
||||
case msgContentType.AUDIO:
|
||||
return renderAudio(value)
|
||||
if (value.startsWith('(') && value.endsWith(')')) {
|
||||
await audioData.loadAudio(props.sessionId, value.slice(1, -1))
|
||||
return renderAudio(value)
|
||||
} else {
|
||||
return h('span', value)
|
||||
}
|
||||
case msgContentType.IMAGE:
|
||||
if (value.startsWith('{') && value.endsWith('}')) {
|
||||
await imageData.loadImageInfoFromContent(props.sessionId, value)
|
||||
@@ -91,12 +104,6 @@ const renderComponent = async (content) => {
|
||||
}
|
||||
}
|
||||
|
||||
const renderAudio = (url) => {
|
||||
return h(AudioMessage, {
|
||||
audioUrl: url
|
||||
})
|
||||
}
|
||||
|
||||
const renderText = (content) => {
|
||||
return h('span', content)
|
||||
}
|
||||
@@ -196,6 +203,21 @@ const renderImage = (content) => {
|
||||
}
|
||||
}
|
||||
|
||||
const renderAudio = (content) => {
|
||||
const audioId = content.slice(1, -1)
|
||||
const url = audioData.audio[audioId]?.url
|
||||
if (url) {
|
||||
return h(AudioMsgBox, {
|
||||
audioUrl: import.meta.env.VITE_OSS_CORS_FLAG + url,
|
||||
onLoad: () => {
|
||||
emit('loadFinished')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return h('span', content)
|
||||
}
|
||||
}
|
||||
|
||||
const msg = computed(() => {
|
||||
return reactive({ ...messageData.getMsg(props.sessionId, props.msgId) })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user