gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
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>