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