From 27cd042df9aca0383a49f3514bc21958dd890912 Mon Sep 17 00:00:00 2001
From: gaoluyang <2820782392@qq.com>
Date: 星期一, 29 六月 2026 15:42:23 +0800
Subject: [PATCH] 银川 1.联调产品维护页面 2.添加IM即时通讯模块
---
src/views/im/home/pages/conversation/components/input/voice-recorder.vue | 340 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 340 insertions(+), 0 deletions(-)
diff --git a/src/views/im/home/pages/conversation/components/input/voice-recorder.vue b/src/views/im/home/pages/conversation/components/input/voice-recorder.vue
new file mode 100644
index 0000000..0598afa
--- /dev/null
+++ b/src/views/im/home/pages/conversation/components/input/voice-recorder.vue
@@ -0,0 +1,340 @@
+<script lang="ts" setup>
+import {
+ computed,
+ onBeforeUnmount,
+ onMounted,
+ onUnmounted,
+ ref,
+ useTemplateRef,
+ watch
+} from 'vue'
+
+import { Button, message } from 'ant-design-vue'
+
+import { formatSeconds } from '#/views/im/utils/time'
+
+defineOptions({ name: 'ImVoiceRecorder' })
+
+const props = withDefaults(
+ defineProps<{
+ maxDuration?: number // 鏈�闀垮綍鍒剁鏁�
+ modelValue: boolean // 鏄惁鏄剧ず
+ }>(),
+ {
+ maxDuration: 60
+ }
+)
+
+const emit = defineEmits<{
+ send: [payload: { blob: Blob; duration: number; extension: string; mimeType: string }] // 褰曞埗瀹屾垚鏁版嵁
+ 'update:modelValue': [value: boolean]
+}>()
+
+const VOICE_MIME_TYPE_OPTIONS = [
+ { extension: 'webm', mimeType: 'audio/webm;codecs=opus' },
+ { extension: 'webm', mimeType: 'audio/webm' },
+ { extension: 'm4a', mimeType: 'audio/mp4' },
+ { extension: 'ogg', mimeType: 'audio/ogg;codecs=opus' },
+ { extension: 'ogg', mimeType: 'audio/ogg' }
+]
+
+const visible = computed({
+ get: () => props.modelValue,
+ set: (v) => emit('update:modelValue', v)
+})
+
+const rootRef = useTemplateRef<HTMLDivElement>('rootRef')
+
+/** 褰曞埗鐘舵�� */
+type Status = 'idle' | 'preview' | 'recording'
+const status = ref<Status>('idle')
+const duration = ref(0)
+const previewUrl = ref('')
+
+let mediaRecorder: MediaRecorder | null = null
+let audioChunks: Blob[] = []
+let mediaStream: MediaStream | null = null
+let timer: null | ReturnType<typeof setInterval> = null
+let recordedBlob: Blob | null = null
+let discarding = false
+let recordingMimeType = ''
+let recordingExtension = ''
+let recordedMimeType = ''
+let recordedExtension = ''
+
+/** 璁℃椂鍣ㄦ枃妗� */
+const timerText = computed(() => formatSeconds(duration.value))
+
+/** 鐩戝惉闈㈡澘鏄剧ず鐘舵�� */
+watch(visible, (v) => {
+ if (v) {
+ document.addEventListener('click', handleDocumentClick)
+ } else {
+ document.removeEventListener('click', handleDocumentClick)
+ resetAll()
+ }
+})
+
+/** 澶勭悊澶栭儴鐐瑰嚮 */
+function handleDocumentClick(e: MouseEvent) {
+ if (!props.modelValue || !rootRef.value) {
+ return
+ }
+ if (rootRef.value.contains(e.target as Node)) {
+ return
+ }
+ if (status.value !== 'idle') {
+ return
+ }
+ visible.value = false
+}
+
+/** 鑾峰彇鏀寔鐨勫綍闊虫牸寮� */
+function getSupportedVoiceMimeType() {
+ if (typeof MediaRecorder === 'undefined' || !MediaRecorder.isTypeSupported) {
+ return undefined
+ }
+ return VOICE_MIME_TYPE_OPTIONS.find((item) => MediaRecorder.isTypeSupported(item.mimeType))
+}
+
+/** 鑾峰彇褰曢煶鏂囦欢鍚庣紑 */
+function getVoiceExtension(mimeType: string) {
+ const normalizedMimeType = mimeType.split(';')[0]
+ if (normalizedMimeType === 'audio/mp4') {
+ return 'm4a'
+ }
+ if (normalizedMimeType === 'audio/ogg') {
+ return 'ogg'
+ }
+ return 'webm'
+}
+
+/** 鍒涘缓褰曢煶鍣� */
+function createVoiceRecorder(stream: MediaStream) {
+ const supportedMimeType = getSupportedVoiceMimeType()
+ const recorder = supportedMimeType
+ ? new MediaRecorder(stream, { mimeType: supportedMimeType.mimeType })
+ : new MediaRecorder(stream)
+ recordingMimeType = recorder.mimeType || supportedMimeType?.mimeType || ''
+ recordingExtension = supportedMimeType?.extension || (recordingMimeType ? getVoiceExtension(recordingMimeType) : '')
+ return recorder
+}
+
+/** 寮�濮嬪綍鍒� */
+async function startRecord() {
+ if (typeof MediaRecorder === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
+ message.error('褰撳墠娴忚鍣ㄤ笉鏀寔褰曢煶锛堥渶瑕� HTTPS 鎴� localhost锛�')
+ return
+ }
+ try {
+ mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
+ } catch {
+ message.error('鏃犳硶鑾峰彇楹﹀厠椋庢潈闄�')
+ return
+ }
+ audioChunks = []
+ discarding = false
+ try {
+ mediaRecorder = createVoiceRecorder(mediaStream)
+ } catch {
+ cleanupStream()
+ message.error('褰撳墠娴忚鍣ㄤ笉鏀寔褰曢煶鏍煎紡')
+ return
+ }
+ mediaRecorder.addEventListener('dataavailable', (event: BlobEvent) => {
+ if (event.data.size > 0) {
+ audioChunks.push(event.data)
+ }
+ })
+ mediaRecorder.addEventListener('stop', () => {
+ if (discarding) {
+ return
+ }
+ recordedMimeType = recordingMimeType || mediaRecorder?.mimeType || audioChunks[0]?.type || 'audio/webm'
+ recordedExtension = recordingExtension || getVoiceExtension(recordedMimeType)
+ recordedBlob = new Blob(audioChunks, { type: recordedMimeType })
+ previewUrl.value = URL.createObjectURL(recordedBlob)
+ status.value = 'preview'
+ })
+ mediaRecorder.start()
+ status.value = 'recording'
+ duration.value = 0
+ timer = setInterval(() => {
+ duration.value++
+ if (duration.value >= props.maxDuration) {
+ stopRecord()
+ }
+ }, 1000)
+}
+
+/** 鍋滄褰曞埗 */
+function stopRecord() {
+ if (duration.value < 1) {
+ message.warning('褰曢煶鏃堕棿澶煭')
+ resetAll()
+ return
+ }
+ if (mediaRecorder && mediaRecorder.state !== 'inactive') {
+ mediaRecorder.stop()
+ }
+ cleanupStream()
+ if (timer) {
+ clearInterval(timer)
+ timer = null
+ }
+}
+
+/** 閲嶆柊褰曞埗 */
+function restart() {
+ clearPreview()
+ duration.value = 0
+ status.value = 'idle'
+}
+
+/** 鍙戦�佸綍闊� */
+function handleSend() {
+ if (!recordedBlob) {
+ return
+ }
+ emit('send', {
+ blob: recordedBlob,
+ duration: duration.value,
+ extension: recordedExtension,
+ mimeType: recordedMimeType || recordedBlob.type
+ })
+ visible.value = false
+}
+
+/** 鍙栨秷褰曞埗 */
+function handleCancel() {
+ visible.value = false
+}
+
+/** 閲嶇疆褰曞埗璧勬簮 */
+function resetAll() {
+ discarding = true
+ if (mediaRecorder && mediaRecorder.state !== 'inactive') {
+ mediaRecorder.stop()
+ }
+ cleanupStream()
+ if (timer) {
+ clearInterval(timer)
+ timer = null
+ }
+ audioChunks = []
+ duration.value = 0
+ status.value = 'idle'
+ recordingMimeType = ''
+ recordingExtension = ''
+ recordedMimeType = ''
+ recordedExtension = ''
+ clearPreview()
+}
+
+/** 閲婃斁棰勮闊抽 */
+function clearPreview() {
+ recordedBlob = null
+ if (previewUrl.value) {
+ URL.revokeObjectURL(previewUrl.value)
+ previewUrl.value = ''
+ }
+}
+
+/** 鍏抽棴楹﹀厠椋庨噰闆� */
+function cleanupStream() {
+ mediaStream?.getTracks().forEach((t) => t.stop())
+ mediaStream = null
+}
+
+onMounted(() => {
+ if (props.modelValue) {
+ document.addEventListener('click', handleDocumentClick)
+ }
+})
+
+onBeforeUnmount(resetAll)
+
+onUnmounted(() => {
+ document.removeEventListener('click', handleDocumentClick)
+})
+</script>
+
+<template>
+ <!-- 璇煶褰曞埗闈㈡澘 -->
+ <div
+ v-if="visible"
+ ref="rootRef"
+ class="im-popover-arrow absolute z-100 w-80 p-4 rounded-md bg-[var(--ant-color-bg-container)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
+ @click.stop
+ >
+ <div class="flex flex-col items-center gap-3">
+ <!-- 褰曞埗鏃堕暱 -->
+ <div class="text-[28px] font-medium tabular-nums text-[var(--ant-color-text)]">
+ {{ timerText }}
+ </div>
+
+ <!-- 鐘舵�佹枃妗� -->
+ <div class="text-13px text-[var(--ant-color-text-secondary)]">
+ <span v-if="status === 'idle'">鐐瑰嚮涓嬫柟鎸夐挳寮�濮嬪綍鍒�</span>
+ <span v-else-if="status === 'recording'">褰曞埗涓紝鏈�闀� {{ maxDuration }} 绉�</span>
+ <span v-else>褰曞埗瀹屾垚锛屽彲璇曞惉鍚庡彂閫�</span>
+ </div>
+
+ <!-- 褰曞埗鐘舵�� -->
+ <div
+ v-if="status !== 'preview'"
+ class="w-12 h-12 rounded-full bg-[var(--ant-color-border)]"
+ :class="{ 'im-voice-recorder__pulse bg-[#f56c6c]': status === 'recording' }"
+ ></div>
+ <audio v-else :src="previewUrl" controls class="w-full"></audio>
+ </div>
+
+ <!-- 鎿嶄綔鎸夐挳 -->
+ <div class="flex justify-end gap-2 mt-3">
+ <template v-if="status === 'idle'">
+ <Button size="small" @click="handleCancel">鍙栨秷</Button>
+ <Button size="small" type="primary" @click="startRecord">寮�濮嬪綍鍒�</Button>
+ </template>
+ <template v-else-if="status === 'recording'">
+ <Button size="small" @click="handleCancel">鍙栨秷</Button>
+ <Button size="small" type="primary" @click="stopRecord">鍋滄褰曞埗</Button>
+ </template>
+ <template v-else>
+ <Button size="small" @click="handleCancel">鍙栨秷</Button>
+ <Button size="small" @click="restart">閲嶆柊褰曞埗</Button>
+ <Button size="small" type="primary" @click="handleSend">鍙戦��</Button>
+ </template>
+ </div>
+ </div>
+</template>
+
+<style scoped>
+/* 搴曢儴灏忎笁瑙� */
+.im-popover-arrow::after {
+ content: '';
+ position: absolute;
+ top: calc(100% - 1px);
+ left: 110px;
+ border-style: solid;
+ border-width: 6px 6px 0 6px;
+ border-color: var(--ant-color-bg-container) transparent transparent transparent;
+ filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08));
+}
+
+/* 褰曢煶涓殑鑴夊啿鍔ㄧ敾 */
+.im-voice-recorder__pulse {
+ animation: im-voice-pulse 1s infinite;
+}
+
+@keyframes im-voice-pulse {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.6);
+ }
+ 70% {
+ box-shadow: 0 0 0 20px rgba(245, 108, 108, 0);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(245, 108, 108, 0);
+ }
+}
+</style>
--
Gitblit v1.9.3