From b64a0deae5b5d33f9e20671a68936b27f0b9b00b Mon Sep 17 00:00:00 2001
From: gaoluyang <2820782392@qq.com>
Date: 星期二, 21 七月 2026 18:03:03 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/dev_pro2.0' into dev_pro2.0

---
 src/views/im/home/pages/conversation/components/input/message-input.vue | 1216 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 1,216 insertions(+), 0 deletions(-)

diff --git a/src/views/im/home/pages/conversation/components/input/message-input.vue b/src/views/im/home/pages/conversation/components/input/message-input.vue
new file mode 100644
index 0000000..6560c6e
--- /dev/null
+++ b/src/views/im/home/pages/conversation/components/input/message-input.vue
@@ -0,0 +1,1216 @@
+<script lang="ts" setup>
+import type { GroupMemberLite } from '../../../../components/group'
+
+import type { Conversation } from '#/views/im/home/types'
+
+import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
+
+import { IconifyIcon as Icon } from '#/packages/icons/src'
+import { isOpenableUrl } from '#/packages/utils/src'
+
+import { Button, DropdownButton, Menu, message, Tooltip } from 'ant-design-vue'
+
+import { uploadFile } from '#/api/infra/file'
+import {
+  ensureMediaSizeWithinLimit,
+  useMediaUploader
+} from '#/views/im/home/composables/useMediaUploader'
+import { useMessageSender } from '#/views/im/home/composables/useMessageSender'
+import { useMuteOverlay } from '#/views/im/home/composables/useMuteOverlay'
+import { useConversationStore } from '#/views/im/home/store/conversationStore'
+import { useFriendStore } from '#/views/im/home/store/friendStore'
+import { useGroupStore } from '#/views/im/home/store/groupStore'
+import { getCurrentUserId } from '#/views/im/utils/auth'
+import { DANGEROUS_FILE_EXTENSIONS, MESSAGE_GROUP_READ_ENABLED } from '#/views/im/utils/config'
+import { ImContentType, ImConversationType, ImGroupMemberRole } from '#/views/im/utils/constants'
+import { getConversationKey } from '#/views/im/utils/conversation'
+import {
+  type FaceMessage,
+  type QuoteMessage,
+  serializeMessage,
+  withQuotePayload
+} from '#/views/im/utils/message'
+import { getMemberDisplayName } from '#/views/im/utils/user'
+
+import { ReplyPreview } from '../message'
+import FacePicker from './face-picker.vue'
+import MentionPicker from './mention-picker.vue'
+import VoiceRecorder from './voice-recorder.vue'
+
+defineOptions({ name: 'ImMessageInput' })
+
+const conversationStore = useConversationStore()
+const groupStore = useGroupStore()
+const friendStore = useFriendStore()
+const { send, sendRaw } = useMessageSender()
+const {
+  uploadAndSendMedia,
+  insertMediaPlaceholder,
+  markMediaFailed,
+  commitMediaPlaceholder,
+  createUploadProgressHandler,
+  verifyMediaUploadStillAllowed,
+  requireMediaHandler
+} = useMediaUploader()
+const muteOverlay = useMuteOverlay() // 绂佽█ / 灏佺瑕嗙洊灞�
+
+const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
+const imageInputRef = useTemplateRef<HTMLInputElement>('imageInputRef')
+const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
+const videoInputRef = useTemplateRef<HTMLInputElement>('videoInputRef')
+const mentionRef = useTemplateRef<InstanceType<typeof MentionPicker>>('mentionRef')
+
+// ==================== 鏂囨湰 / 鍙戦�� ====================
+const canSend = ref(false) // editor 鏄惁鏈夊彲鍙戦�佸唴瀹癸紱contenteditable 娌� v-model锛岄潬 input 浜嬩欢涓诲姩鍚屾
+
+/** 缁存姢 canSend + data-empty锛堟拺璧� placeholder锛夛紱涓嶅啓鑽夌锛宺estoreDraftToEditor 澶嶇敤閬垮厤鍥炴祦 */
+function applyEditorUiState(editor: HTMLDivElement) {
+  const raw = editor.textContent || ''
+  // canSend 鎸� trim 鍚庡垽鏂紙绌烘牸 / 鎹㈣涓嶇畻鍙彂閫佸唴瀹癸級锛涚瑷�鎬佺洿鎺ョ鐢�
+  canSend.value = !!raw.trim() && !!conversationStore.activeConversation && !muteOverlay.value
+  // data-empty 鎸夊師濮嬪唴瀹瑰垽鏂細鐢ㄦ埛鏁蹭竴涓┖鏍间篃瑕佽 placeholder 闅愯棌锛岄伩鍏嶈瑙夊彔鍔�
+  // 鐢ㄥ睘鎬�"瀛樺湪 / 缂哄け"鑰岄潪 'true'/'false' 瀛楃涓诧細CSS [data-empty]::before 鍛戒腑鍗冲彲锛�
+  // 姣� [data-empty='true'] 鐩磋锛涙祻瑙堝櫒鍒犵┖鍚庣暀 <br> 鈫� :empty 涓嶅懡涓紝鎵�浠ュ繀椤� JS 缁存姢
+  if (raw) {
+    delete editor.dataset.empty
+  } else {
+    editor.dataset.empty = ''
+  }
+}
+
+/** 鐢ㄦ埛缂栬緫鍏ュ彛鐨勭粺涓�鏀跺熬锛歎I 鐘舵�佸悓姝� + 鑽夌鍐欏洖 store锛堝垪琛ㄧ珛鍗冲嚭 [鑽夌] 鍓嶇紑锛� */
+function syncEditorState() {
+  const editor = editorRef.value
+  if (!editor) {
+    return
+  }
+  applyEditorUiState(editor)
+  syncDraftToStore(editor)
+}
+
+/** 绂佽█鐘舵�佸彉鍖栨椂鍚屾鍙戦�佹寜閽� */
+watch(muteOverlay, () => {
+  const editor = editorRef.value
+  if (editor) {
+    applyEditorUiState(editor)
+  }
+})
+
+/** 鎶� editor 褰撳墠鍐呭鍐欏埌浼氳瘽鑽夌锛沺lain 鐢� collectFromEditor 鎷匡紝涓庡彂閫佹椂鍚屾簮閬垮厤鍒楄〃涓庡疄鍙戜笉涓�鑷� */
+function syncDraftToStore(editor: HTMLDivElement) {
+  const conversation = conversationStore.activeConversation
+  if (!conversation) {
+    return
+  }
+  // collectFromEditor 宸� trim锛宲lain 涓虹┖鏃� store 鍐呴儴鎸� clearConversationDraft 澶勭悊
+  // reply 閫忎紶褰撳墠蹇収锛歴etConversationDraft 鏄暣瀵硅薄鏇挎崲锛屼笉璇绘棫 reply 浼氳鐢ㄦ埛姣忔暡涓�涓敭灏辨妸寮曠敤鏉℃摝鎺�
+  const { text } = collectFromEditor(editor)
+  const existing = conversationStore.getConversationDraft(conversation)
+  conversationStore.setConversationDraft(conversation, {
+    html: editor.innerHTML,
+    plain: text,
+    reply: existing?.reply
+  })
+}
+
+/** 鍒囦細璇濇椂鎶� store 閲岀殑鑽夌杩樺師鍒� editor锛涘彧鏇� UI 涓嶅洖鍐欒崏绋匡紝閬垮厤 store鈫抏ditor鈫抯tore 鍥炴祦 */
+function restoreDraftToEditor() {
+  const editor = editorRef.value
+  if (!editor) {
+    return
+  }
+  const conversation = conversationStore.activeConversation
+  const draft = conversation ? conversationStore.getConversationDraft(conversation) : undefined
+  editor.innerHTML = draft?.html || ''
+  applyEditorUiState(editor)
+  // 鎶婂厜鏍囩Щ鍒版湯灏撅紝璁╃敤鎴锋帴鐫�杈撳叆锛涚┖鍐呭鐩存帴 focus 鍗冲彲
+  if (draft?.html) {
+    placeCaretAtEnd(editor)
+  }
+}
+
+/** 鎶婂厜鏍囨斁鍒� contenteditable 鍏冪礌鐨勬湯灏锯�斺�斿垏鍥炴湁鑽夌鐨勪細璇濇椂鍏夋爣鑷劧钀藉湪灏鹃儴锛屽榻愬井淇� */
+function placeCaretAtEnd(el: HTMLElement) {
+  const range = document.createRange()
+  range.selectNodeContents(el)
+  range.collapse(false)
+  const sel = window.getSelection()
+  if (!sel) {
+    return
+  }
+  sel.removeAllRanges()
+  sel.addRange(range)
+}
+
+/**
+ * 璧� DOM 鎶� editor 鍐呭鎷煎洖 text + atUserIds
+ *
+ * 鑺傜偣鍒嗘敮锛�
+ * 1. text 鑺傜偣锛氱洿鎺ユ嫾 textContent锛涜繃绋嬩腑婊ゆ帀 ZWSP锛坱oken 棣栦綅閿氱偣鐢紝涓嶈繘鍙戦�佸唴瀹癸級
+ * 2. br锛氭嫾 \n锛圫hift+Enter 璧� execCommand('insertLineBreak') 浜х墿锛�
+ * 3. span[data-id]锛氭嫾 token 鏄剧ず鏂囨湰 + 鎶� dataset.id 鏀跺埌 atUserIds锛堜笉閫掑綊 span 鍐呴儴锛�
+ * 4. div锛氭祻瑙堝櫒鍦� contenteditable 閲岄粯璁ゆ崲琛屽鍣紝鍓嶇疆 \n 鍚庨�掑綊瀛愯妭鐐�
+ * 5. 鍏朵粬鍏冪礌锛氶�忎紶锛岄�掑綊瀛愯妭鐐�
+ *
+ * atUserIds 璧� Set 鍘婚噸锛氱敤鎴� @ 寮犱笁涓ゆ鏃� atUserIds 鍙嚭鐜颁竴娆★紱trim 鏈熬绌虹櫧
+ */
+function collectFromEditor(root: HTMLElement): { atUserIds: number[]; text: string; } {
+  const userIds: number[] = []
+  let text = ''
+
+  function walk(node: Node) {
+    if (node.nodeType === Node.TEXT_NODE) {
+      text += (node.textContent || '').replaceAll('鈥�', '')
+      return
+    }
+    if (node.nodeType !== Node.ELEMENT_NODE) {
+      return
+    }
+    const el = node as HTMLElement
+    const tag = el.tagName.toLowerCase()
+    if (tag === 'br') {
+      text += '\n'
+      return
+    }
+    if (tag === 'span' && el.dataset.id) {
+      text += el.textContent || ''
+      const id = Number(el.dataset.id)
+      if (!Number.isNaN(id)) {
+        userIds.push(id)
+      }
+      return
+    }
+    if (tag === 'div') {
+      if (text && !text.endsWith('\n')) {
+        text += '\n'
+      }
+      el.childNodes.forEach((child) => walk(child))
+      return
+    }
+    el.childNodes.forEach((child) => walk(child))
+  }
+
+  // 鐩存帴浠� root.childNodes 寮�濮嬶紝閬垮厤鎶� root 鏈韩涔熷綋鍏冪礌澶勭悊锛堣櫧鐒剁洰鍓嶆病鏈夌壒娈婃牱寮忥紝浣嗕互闃叉湭鏉ユ敼鍔級
+  root.childNodes.forEach((child) => walk(child))
+  return {
+    text: text.trim(),
+    atUserIds: [...new Set(userIds)]
+  }
+}
+
+/**
+ * 鍙戦�侊細浠� DOM 鏀� text + atUserIds 鈫� 娓呯┖缂栬緫鍣� 鈫� 璧� useMessageSender.send
+ *
+ * 1. 闃插尽锛歝anSend 鏄� false锛坱rim 鍚庣┖ / 娌℃縺娲讳細璇濓級鎴� editor 娌� mount 鈫� 鐩存帴 return
+ * 2. 鏀堕泦锛欴OM walk 鎷垮埌瑕佸彂閫佺殑鏂囨湰 + atUserIds
+ * 3. 浜屾闃插尽锛歝ollectFromEditor 璧� trim锛屽彲鑳芥瘮 syncEditorState 鏇翠弗鏍硷紙渚嬪鍏� ZWSP锛夛紝浠嶇┖灏� return
+ * 4. 娓呯┖ + 鍚屾鐘舵�侊細鍏堟竻 innerHTML 鍐� syncEditorState 璁� placeholder / canSend 涓�璧峰洖褰�
+ *    锛堥『搴忓緢閲嶈锛氬厛娓呭悗 sync锛屽惁鍒� sync 鐪嬪埌鏃у唴瀹逛細璇垽锛�
+   * 5. 涓婇�侊細atUserIds 闈炵┖鎵嶄紶锛岄伩鍏嶅彂绌烘暟缁勶紱quote 鐢� clearConversationDraft 鍓嶆姄鍙栵紝纭繚寮曠敤鏉$珛鍗虫秷澶�
+ */
+async function handleSend(options?: { receipt?: boolean }) {
+  const editor = editorRef.value
+  if (!canSend.value || !editor || muteOverlay.value) {
+    return
+  }
+  const { text, atUserIds } = collectFromEditor(editor)
+  if (!text) {
+    return
+  }
+  // 1. 鎶� quote 鍚庢竻绌� editor + 褰撳墠浼氳瘽鑽夌锛堝寘鍚� reply锛夛紱syncEditorState 鍚� / reply 閮戒负绌�
+  //    store 鍐呴儴浼氳嚜鍔ㄦ竻鐞嗭紝浣嗘樉寮� clearConversationDraft 鑳界珛鍗冲悓姝ャ�佷笉渚濊禆 debounce 鏃跺簭锛屽垪琛ㄤ笂鐨� [鑽夌] 绔嬪嵆娑堝け
+  const replyQuote = replyTarget.value
+  editor.innerHTML = ''
+  if (conversationStore.activeConversation) {
+    conversationStore.clearConversationDraft(conversationStore.activeConversation)
+  }
+  syncEditorState()
+  // 2. 鍙戦��
+  await send(text, {
+    atUserIds: atUserIds.length > 0 ? atUserIds : undefined,
+    receipt: options?.receipt,
+    quote: replyQuote
+  })
+}
+
+/** 鍙戦�佹寜閽笅鎷夎彍鍗曟搷浣� */
+function handleSendMenuClick({ key }: { key: number | string }) {
+  if (key === 'receipt') {
+    handleSend({ receipt: true })
+  }
+}
+
+// ==================== 閫夊尯 / 鎻掑叆 ====================
+/**
+ * 涓婃钀藉湪 editor 鍐呯殑 selection锛堢劍鐐硅琛ㄦ儏闈㈡澘绛夊ず璧版椂鐢ㄦ潵鍥炲埌鍘熸彃鍏ョ偣锛�
+ *
+ * 鐩戝惉 document.selectionchange 姣� editor.@blur 鏇村彲闈狅細blur 鏃� selection 宸茬粡绉昏蛋
+ */
+let savedRange: null | Range = null
+
+/**
+ * 璧� native execCommand锛屼繚鐣欐祻瑙堝櫒鍘熺敓 undo 鏍�
+ *
+ * execCommand 鍦� lib.dom 閲岃鏍� @deprecated锛圛DE 鏄剧ず鍒犻櫎绾匡級锛屼絾 'insertText' /
+ * 'insertLineBreak' 娌℃湁绛変环鐨� W3C 鏍囧噯鏇夸唬鈥斺�擱ange/Selection 鑷繁鎷� DOM 浼氳 Ctrl+Z 澶辨晥銆�
+ * 闆嗕腑鍒拌繖閲岃皟鐢紝璋冪敤鐐逛笉蹇呮暎钀� ts-expect-error锛涗笉寮曞叆 eslint disable锛堥」鐩綋鍓�
+ * @typescript-eslint v7 娌℃湁 no-deprecated 瑙勫垯锛屽姞浜嗕篃鏃犳晥锛屽弽鑰岃 lint 鎶�"瑙勫垯涓嶅瓨鍦�"锛�
+ */
+function nativeExec(command: 'insertLineBreak' | 'insertText', value?: string) {
+  document.execCommand(command, false, value)
+}
+
+/**
+ * document selectionchange 鐩戝惉锛氭妸钀藉湪 editor 鍐呯殑 selection 缂撳瓨鍒� savedRange锛�
+ * insertText 鍦ㄧ劍鐐硅鍋疯蛋鍚庣敤瀹冩妸鍏夋爣鎭㈠鍒板師鎻掑叆鐐�
+ */
+function onSelectionChange() {
+  const editor = editorRef.value
+  const sel = window.getSelection()
+  if (!editor || !sel || sel.rangeCount === 0) {
+    return
+  }
+  const range = sel.getRangeAt(0)
+  if (editor.contains(range.startContainer)) {
+    savedRange = range.cloneRange()
+  }
+}
+
+/**
+ * 鐐瑰嚮 editor / picker 澶栭儴鏃跺叧鎺夋诞灞傦紝閬垮厤杈撳叆 @keyword 鍚庣敤鎴风偣鍒娴眰涓嶆秷澶�
+ *
+ * 鐢� mousedown 鑰岄潪 click锛歝lick 鍦ㄦ煇浜涙祻瑙堝櫒閲� picker 鍏冪礌娑堝け鍚庡洖涓嶅埌鍘� target锛屼細琚悶鎺�
+ */
+function onDocMousedown(e: MouseEvent) {
+  if (!mentionVisible.value) {
+    return
+  }
+  const target = e.target as Node | null
+  if (!target) {
+    return
+  }
+  if (editorRef.value?.contains(target)) {
+    return
+  }
+  // picker 鏄� fixed 瀹氫綅鐨勫厔寮熻妭鐐癸紝涓嶅湪 editor 瀛愭爲閲岋紱鐢ㄧ被鍚嶅畾浣�
+  const pickerEl = document.querySelector('.message-input__mention-picker')
+  if (pickerEl?.contains(target)) {
+    return
+  }
+  closeMention()
+}
+
+onMounted(() => {
+  document.addEventListener('selectionchange', onSelectionChange)
+  document.addEventListener('mousedown', onDocMousedown)
+  restoreDraftToEditor()
+})
+
+onBeforeUnmount(() => {
+  document.removeEventListener('selectionchange', onSelectionChange)
+  document.removeEventListener('mousedown', onDocMousedown)
+})
+
+/**
+ * 鍒囦細璇濇椂杩樺師瀵规柟鐨勮崏绋垮埌 editor
+ *
+ * 鍚屾鍏� @ / 琛ㄦ儏 / 璇煶娴眰骞舵竻 savedRange锛�
+ * - mentionRange / savedRange 鏃у紩鐢ㄨ繕鎸囧悜涓婁竴浼氳瘽鐨� DOM 鑺傜偣锛屼笉娓呬笅娆℃彃 token 浼氳惤閿欎綅缃�
+ * - 璇煶褰曞埗寮圭獥淇濈暀鏃讹紝褰曞畬瑙﹀彂鐨� onVoiceSend 浼氳褰撳墠 activeConversation锛屾妸璇煶鍙戝埌鏂颁細璇�
+ */
+watch(
+  () =>
+    conversationStore.activeConversation
+      ? getConversationKey(conversationStore.activeConversation)
+      : null,
+  () => {
+    closeMention()
+    emojiVisible.value = false
+    voiceVisible.value = false
+    savedRange = null
+    restoreDraftToEditor()
+  }
+)
+
+/**
+ * 鎶婂瓧绗︿覆鎻掑叆鍏夋爣澶勶紙emoji 闈㈡澘绛夊満鏅皟鐢級
+ *
+ * 1. editor 娌℃寕鐩存帴杩斿洖
+ * 2. 鐒︾偣鍥炲埌 editor + 鎶� savedRange 鎭㈠鎴愬綋鍓� selection锛坋moji 闈㈡澘鍋风劍鐐瑰悗杩樿兘鍥炲師浣嶏級
+ * 3. nativeExec 鎻掓枃鏈紝淇濈暀娴忚鍣ㄥ師鐢� undo 鏍�
+ * 4. 鍚屾 canSend / placeholder
+ */
+function insertText(str: string) {
+  const editor = editorRef.value
+  if (!editor) {
+    return
+  }
+  editor.focus()
+  if (savedRange) {
+    const sel = window.getSelection()
+    if (sel) {
+      sel.removeAllRanges()
+      sel.addRange(savedRange)
+    }
+  }
+  // 1. nativeExec 鎻掓枃鏈紝淇濈暀娴忚鍣ㄥ師鐢� undo 鏍�
+  nativeExec('insertText', str)
+  // 2. 鍚屾 canSend / placeholder
+  syncEditorState()
+}
+
+/**
+ * 绮樿创澶勭悊
+ *
+ * 1. 浼樺厛鎵� clipboardData.items 鎵炬枃浠剁被鍨嬫潯鐩紙鎴浘銆佹嫋鍏ョ殑鍥剧墖 / 鏂囦欢绛夛級
+ *    - image/* 鈫� 璧� IMAGE 涓婁紶鍙戦��
+ *    - 鍏跺畠 file 鈫� 璧� FILE 涓婁紶鍙戦��
+ *    - 涓�娆$矘璐村彧澶勭悊绗竴涓枃浠讹紝閬垮厤涓�娆$矘璐村彂鍑哄鏉℃秷鎭�
+ * 2. 娌℃枃浠跺啀璧� text锛氬墺鎺夊閮ㄦ牱寮� / 鑴氭湰锛岄伩鍏嶅绔� inline style 姹℃煋 editor
+ *    锛坈ontenteditable 榛樿绮樿创浼氬甫 HTML锛屾墍浠ユā鏉夸笂 @paste.prevent 鎷︽埅锛�
+ */
+function onPaste(e: ClipboardEvent) {
+  // 1. 浼樺厛鎵� clipboardData.items 鎵炬枃浠剁被鍨嬫潯鐩紙鎴浘銆佹嫋鍏ョ殑鍥剧墖 / 鏂囦欢绛夛級
+  const items = e.clipboardData?.items
+  if (items?.length) {
+    for (const item of items) {
+      if (!item) {
+        continue
+      }
+      if (item.kind !== 'file') {
+        continue
+      }
+      const file = item.getAsFile()
+      if (!file) {
+        continue
+      }
+      if (item.type.startsWith('image/')) {
+        void uploadAndSendImage(file)
+      } else {
+        void uploadAndSendFile(file)
+      }
+      return
+    }
+  }
+  // 2. 娌℃枃浠跺啀璧� text锛氬墺鎺夊閮ㄦ牱寮� / 鑴氭湰锛岄伩鍏嶅绔� inline style 姹℃煋 editor
+  const text = e.clipboardData?.getData('text/plain') || ''
+  if (text) {
+    nativeExec('insertText', text)
+    // @paste.prevent 闃绘柇浜嗘祻瑙堝櫒榛樿 input 浜嬩欢锛岄渶鎵嬪姩鍚屾鑽夌 / canSend锛屼笌 insertText() 璺緞涓�鑷�
+    syncEditorState()
+  }
+}
+
+/** 缂栬緫鍣ㄥ唴瀹瑰彉鍖栫殑缁熶竴鍏ュ彛锛氬厛鍚屾 canSend / placeholder锛屽啀鍒� @ 娴眰鏄惁瑕佸睍寮� */
+function onInput() {
+  syncEditorState()
+  detectAtMention()
+}
+
+// ==================== 寮曠敤 / 鍥炲 ====================
+
+/** 褰撳墠浼氳瘽鐨勩�屾鍦ㄥ洖澶嶃�嶅璞★紝浠庝細璇濊崏绋挎淳鐢� */
+const replyTarget = computed<QuoteMessage | undefined>(() => {
+  const conversation = conversationStore.activeConversation
+  if (!conversation) {
+    return undefined
+  }
+  return conversationStore.getConversationDraft(conversation)?.reply
+})
+
+/** 娓呮帀褰撳墠 reply 浣嗕繚鐣欐鏂囪崏绋匡細鐐� 脳 鍏抽棴 / 鍙戦�佸嵆灏嗚繘琛屾椂璋� */
+function clearConversationReplyDraft() {
+  const conversation = conversationStore.activeConversation
+  if (!conversation) {
+    return
+  }
+  conversationStore.clearConversationReplyDraft(conversation)
+}
+
+/** 鍙栬蛋褰撳墠 reply 蹇収锛堟姄涓�娆℃竻涓�娆★級锛屽獟浣撲笂浼犻摼璺湪鍔ㄦ墜鍓嶇粺涓�璋冨畠鎷� quote */
+function consumeReply(): QuoteMessage | undefined {
+  const quote = replyTarget.value
+  if (quote) {
+    clearConversationReplyDraft()
+  }
+  return quote
+}
+
+// ==================== 琛ㄦ儏 ====================
+const emojiVisible = ref(false)
+/** 鍒囨崲琛ㄦ儏闈㈡澘锛涙墦寮�鏃朵簰鏂ュ叧鎺夎闊抽潰鏉� */
+function toggleEmoji() {
+  emojiVisible.value = !emojiVisible.value
+  if (emojiVisible.value) {
+    voiceVisible.value = false
+  }
+}
+
+/** 閫変腑琛ㄦ儏璐村浘锛氭嫾 FaceMessage payload 鐩存帴璧� sendRaw 鍙戦�侊紙quote 澶嶇敤褰撳墠 reply 蹇収锛� */
+async function onSelectFace(face: { height: number; name?: string; url: string; width: number; }) {
+  if (muteOverlay.value) {
+    return
+  }
+  const conversation = conversationStore.activeConversation
+  if (!conversation) {
+    return
+  }
+  const replyQuote = consumeReply()
+  const payload = withQuotePayload<FaceMessage>(
+    { url: face.url, width: face.width, height: face.height, name: face.name },
+    replyQuote
+  )
+  await sendRaw(ImContentType.FACE, serializeMessage(payload), { conversation })
+}
+
+// ==================== @ 鎴愬憳閫夋嫨锛堢兢鑱婏級 ====================
+const isGroup = computed(
+  () => conversationStore.activeConversation?.type === ImConversationType.GROUP
+)
+
+/** 浠� groupStore 璇诲綋鍓嶆縺娲荤兢鐨勬垚鍛橈紙鍒囦細璇濇椂鐢� MessagePanel 棰勬媺锛� */
+const groupMembers = computed<GroupMemberLite[]>(() => {
+  const conversation = conversationStore.activeConversation
+  if (!conversation || conversation.type !== ImConversationType.GROUP) {
+    return []
+  }
+  const group = groupStore.getGroup(conversation.targetId)
+  return (group?.members || []).map((member) => {
+    const friend = friendStore.getFriend(member.userId)
+    return {
+      userId: member.userId,
+      showName: getMemberDisplayName(member, friend),
+      nickname: member.nickname,
+      avatar: member.avatar,
+      status: member.status
+    }
+  })
+})
+
+/** 褰撳墠鐢ㄦ埛鏄惁鑳� @ 鍏ㄥ憳锛涚兢涓� + 绠$悊鍛橀兘鍏佽锛堝榻愬井淇� PC锛歛dmin 涔熻兘 @ 鎵�鏈変汉锛� */
+const canAtAll = computed<boolean>(() => {
+  const conversation = conversationStore.activeConversation
+  if (!conversation || conversation.type !== ImConversationType.GROUP) {
+    return false
+  }
+  const group = groupStore.getGroup(conversation.targetId)
+  if (!group) {
+    return false
+  }
+  const myId = getCurrentUserId()
+  if (!myId) {
+    return false
+  }
+  if (group.ownerUserId === myId) {
+    return true
+  }
+  return group.members?.find((member) => member.userId === myId)?.role === ImGroupMemberRole.ADMIN
+})
+
+const mentionVisible = ref(false)
+const mentionSearchText = ref('')
+/** 娴眰瀹氫綅锛歺 鏄乏杈硅窛锛泃op / bottom 浜岄�変竴鈥斺�� bottom 閿氬畾锛坧icker 涓嬫部璐� @锛夋槸榛樿锛�
+ *  涓婃柟鏀句笉涓嬫椂閫�鍖栦负 top 閿氬畾锛坧icker 涓婃部璐� @ 涓嬫柟锛� */
+const mentionPosition = ref<{ bottom?: number; top?: number; x: number; }>({ x: 0, bottom: 0 })
+
+/** MentionPicker 鐨勫鍣ㄥ搴︼紙涓庣粍浠堕噷鐨� w-50 瀵归綈锛夛紝鐢ㄤ簬瑙嗗彛鍙虫部鍥炲脊锛�
+ *  楂樺害涓嶅啀鐢ㄥ父閲忕畻浣嶇疆鈥斺�攂ottom 閿氬畾鍚� picker 鍐呭澶氬閮戒笉褰卞搷涓嬫部浣嶇疆锛岃嚜鐒惰创 @ */
+const MENTION_WIDTH = 200
+const MENTION_MIN_FIT_ABOVE = 120 // 涓婃柟鍓╀綑绌洪棿鑷冲皯杩欎箞澶氭墠鏀句笂鏂癸紝鍚﹀垯缈诲埌涓嬫柟锛堥伩鍏� picker 琚鍙i《 / 椤堕儴 chat header 鍒囨帀锛�
+
+/** 褰撳墠 @ 鍏抽敭璇嶅湪 editor 閲岀殑鑼冨洿锛沷nMentionSelect 鐢ㄥ畠瀹氫綅鍒犻櫎 + 鎻掑叆 token */
+let mentionRange: null | Range = null
+
+/** 鍏抽棴娴眰 + 娓呮帀 range锛岄伩鍏嶄笂娆℃畫鐣欑殑 range 琚笅涓�娆� onMentionSelect 璇敤 */
+function closeMention() {
+  mentionVisible.value = false
+  mentionRange = null
+}
+
+/** 鍦ㄥ厜鏍囧綋鍓嶆枃鏈妭鐐归噷鍚戝墠鎵� @keyword锛屽懡涓垯灞曞紑娴眰 */
+function detectAtMention() {
+  if (!isGroup.value) {
+    closeMention()
+    return
+  }
+  const sel = window.getSelection()
+  if (!sel || sel.rangeCount === 0) {
+    closeMention()
+    return
+  }
+  const range = sel.getRangeAt(0)
+  if (!range.collapsed || range.startContainer.nodeType !== Node.TEXT_NODE) {
+    closeMention()
+    return
+  }
+  const node = range.startContainer
+  const offset = range.startOffset
+  const before = (node.textContent || '').slice(0, offset)
+  // 鐩存帴鎵炬渶杩戠殑 @锛氫笉闄愬埗鍓嶇疆瀛楃锛屽榻愬井淇�"涓枃绱ц创 @ 涔熻兘鑱旀兂"锛堜綘濂紷寮犱笁銆侊紝@寮犱笁 閮借Е鍙戯級锛�
+  // 浠d环鏄� email-like "test@example.com" 涔熶細瑙﹀彂锛屼絾鑱婂ぉ杈撳叆閲岀矘 email 鐨勬鐜囦綆锛�
+  // 涓旂敤鎴锋寜 Esc 鍗冲彲鍏虫诞灞傦紱鍏滃簳鐢� [^\s@] 淇濊瘉涓�鏃﹁緭鍏ョ┖鏍� / 绗簩涓� @ 灏卞仠涓�
+  const match = before.match(/@([^\s@]*)$/)
+  if (!match) {
+    closeMention()
+    return
+  }
+  const keyword = match[1] ?? ''
+  const atOffset = offset - keyword.length - 1
+  mentionRange = document.createRange()
+  mentionRange.setStart(node, atOffset)
+  mentionRange.setEnd(node, offset)
+  mentionSearchText.value = keyword
+  // 閿氬畾鍦� @ 绗﹀彿鏈韩锛岃�岄潪褰撳墠 caret鈥斺�斿惁鍒欑敤鎴锋瘡澶氭暡涓�涓瓧娴眰灏辫窡鐫�鍙崇Щ锛�"椋�"锛�
+  positionMention(node, atOffset)
+  mentionVisible.value = true
+}
+
+/**
+ * 娴眰浣嶇疆锛氶粯璁� bottom 閿氬畾锛坧icker 涓嬫部璐� @ 涓婃柟 8px锛夛紝涓婃柟涓嶅鎵嶇炕鎴� top 閿氬畾
+ *
+ * 1. 璁$畻 @ 瀛楃灞忓箷鍧愭爣 rect
+ * 2. 妯悜锛歱icker 宸﹁竟瀵归綈 @锛岃秺杩囪鍙e彸娌垮垯宸︽帹锛涜嚦灏戠暀 8px 鐣欑櫧
+ * 3. 绾靛悜锛氫笂鏂瑰墿浣� 鈮� MENTION_MIN_FIT_ABOVE 璧� bottom 閿氬畾锛堜笉渚濊禆 picker 瀹為檯楂樺害锛�
+ *    鏃犺 1 椤硅繕鏄� N 椤� picker 涓嬫部閮借创 @锛夛紱涓嶅鍒欑炕鍒� @ 涓嬫柟璧� top 閿氬畾
+ */
+function positionMention(node: Node, atOffset: number) {
+  // 1. 璁$畻 @ 瀛楃灞忓箷鍧愭爣 rect
+  const anchor = document.createRange()
+  anchor.setStart(node, atOffset)
+  anchor.collapse(true)
+  const rect = anchor.getBoundingClientRect()
+  // 2. 妯悜锛歱icker 宸﹁竟瀵归綈 @锛岃秺杩囪鍙e彸娌垮垯宸︽帹锛涜嚦灏戠暀 8px 鐣欑櫧
+  const left = Math.max(8, Math.min(rect.left, window.innerWidth - MENTION_WIDTH - 8))
+  // 3. 绾靛悜锛氫笂鏂瑰墿浣� 鈮� MENTION_MIN_FIT_ABOVE 璧� bottom 閿氬畾
+  mentionPosition.value = rect.top >= MENTION_MIN_FIT_ABOVE ? { x: left, bottom: window.innerHeight - rect.top + 8 } : { x: left, top: rect.bottom + 8 };
+}
+
+/** editor 鍐呴儴婊氬姩鏃跺悓姝ユ诞灞備綅缃紙澶氳 + 瑙﹀彂婊氬姩鏉″満鏅級 */
+function onEditorScroll() {
+  if (!mentionVisible.value || !mentionRange) {
+    return
+  }
+  positionMention(mentionRange.startContainer, mentionRange.startOffset)
+}
+
+function onMentionSelect(member: GroupMemberLite) {
+  const editor = editorRef.value
+  if (!editor || !mentionRange) {
+    return
+  }
+  // 鍒� @keyword锛屾彃鍏� contenteditable=false 鐨� token锛�
+  // 鍒犻櫎鏃舵暣娈垫秷闄� + 涓嶄細琚厜鏍囨媶绌匡紱data-id 鏄悗缁� collectFromEditor 鏀� atUserIds 鐨勯挬瀛�
+  // token 鏂囨湰鍥哄畾璧扮湡瀹炴樀绉帮細缇ら噷鎵�鏈夋垚鍛樼湅鍒扮殑瀛楅潰閲忎竴鑷达紝閬垮厤鎴戜晶鐨勫ソ鍙嬪娉� / 缇ゆ樀绉版薄鏌撳彂閫佹枃鏈�
+  mentionRange.deleteContents()
+  const span = document.createElement('span')
+  span.className = 'mention-token'
+  span.dataset.id = String(member.userId)
+  span.contentEditable = 'false'
+  span.textContent = `@${member.nickname || member.showName}`
+  mentionRange.insertNode(span)
+  // token 鍦� editor 棣栦綅鏃讹紝contenteditable=false 杈圭紭浼氳鍏夋爣鏃犳硶鎸埌 token 鍓�
+  // 琛ヤ竴涓浂瀹界┖鏍煎綋閿氱偣锛汥OM walk 鏃朵細琚护鎺夛紝涓嶈繘鍏ュ彂閫佸唴瀹�
+  const prev = span.previousSibling
+  if (!prev || (prev.nodeType === Node.TEXT_NODE && !prev.textContent)) {
+    span.parentNode?.insertBefore(document.createTextNode('\u200B'), span)
+  }
+  // 鍦� token 鍚庤ˉ涓�涓� NBSP锛岃鍏夋爣鍙互缁х画杈撳叆锛汵BSP 姣旀櫘閫氱┖鏍兼洿绋筹紝閬垮厤琚祻瑙堝櫒鎶樺彔
+  const space = document.createTextNode('\u00A0')
+  span.parentNode?.insertBefore(space, span.nextSibling)
+  // 鍏夋爣绉诲埌 NBSP 涔嬪悗
+  const sel = window.getSelection()
+  if (sel) {
+    const newRange = document.createRange()
+    newRange.setStartAfter(space)
+    newRange.collapse(true)
+    sel.removeAllRanges()
+    sel.addRange(newRange)
+  }
+  closeMention()
+  editor.focus()
+  syncEditorState()
+}
+
+/**
+ * 閿洏浜嬩欢鍒嗗彂
+ *
+ * 1. mention 娴眰鎵撳紑鏃�
+ *    1.1 鈫�/鈫� 绉诲姩楂樹寒
+ *    1.2 Enter 鏈夊�欓�� 鈫� 閫変腑锛涙棤鍊欓�� fall through 鍒颁笅闈㈢殑鍙戦�佸垎鏀紝閬垮厤鎸� Enter 娌″弽搴�
+ *    1.3 Esc 鍏虫诞灞�
+ * 2. Shift+Enter 鎹㈣锛氬己鍒惰蛋 br锛堟祻瑙堝櫒榛樿浼氭彃 div锛孌OM walk 鎷兼帴鏇村鏉傦級
+ * 3. 鏅�� Enter 鍙戦�侊紙IME composition 鏈熼棿涓嶈Е鍙戯紝閬垮厤閫夎瘝琚鍙戯級
+ */
+function onKeydown(e: KeyboardEvent) {
+  // 1. mention 娴眰鎵撳紑鏃�
+  if (mentionVisible.value) {
+    if (e.key === 'ArrowUp') {
+      e.preventDefault()
+      mentionRef.value?.moveUp()
+      return
+    }
+    if (e.key === 'ArrowDown') {
+      e.preventDefault()
+      mentionRef.value?.moveDown()
+      return
+    }
+    if (e.key === 'Enter' && !e.isComposing && mentionRef.value?.hasCandidates()) {
+        e.preventDefault()
+        mentionRef.value?.pickActive()
+        return
+      }
+    if (e.key === 'Escape') {
+      closeMention()
+      return
+    }
+  }
+  // 2. Shift+Enter 鎹㈣锛氬己鍒惰蛋 br锛堟祻瑙堝櫒榛樿浼氭彃 div锛孌OM walk 鎷兼帴鏇村鏉傦級
+  if (e.key === 'Enter' && e.shiftKey && !e.isComposing) {
+    e.preventDefault()
+    nativeExec('insertLineBreak')
+    syncEditorState()
+    return
+  }
+  // 3. 鏅�� Enter 鍙戦�侊紙IME composition 鏈熼棿涓嶈Е鍙戯紝閬垮厤閫夎瘝琚鍙戯級
+  if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.isComposing) {
+    e.preventDefault()
+    handleSend()
+  }
+}
+
+// ==================== 鍥剧墖 / 鏂囦欢 / 璇煶 涓婁紶 ====================
+
+/** 涓婁紶鍓嶇殑缁熶竴鎷︽埅锛氱瑷�鎬� / 鏃犳縺娲讳細璇濈洿鎺ユ斁寮冿紱杩斿洖褰撳墠 conversation 涓庢姄璧扮殑 quote */
+function prepareMediaUpload(): undefined | { conversation: Conversation; quote?: QuoteMessage } {
+  if (muteOverlay.value) {
+    return undefined
+  }
+  const conversation = conversationStore.activeConversation
+  if (!conversation) {
+    return undefined
+  }
+  return { conversation, quote: consumeReply() }
+}
+
+/** 涓婁紶骞跺彂閫� IMAGE 娑堟伅锛堝崰浣� + 杩涘害 + 鐪熷疄 url ack 鐢� useMediaUploader 澶勭悊锛� */
+async function uploadAndSendImage(file: File) {
+  const context = prepareMediaUpload()
+  if (!context) {
+    return
+  }
+  await uploadAndSendMedia({
+    file,
+    type: ImContentType.IMAGE,
+    quote: context.quote,
+    conversation: context.conversation
+  })
+}
+
+/** 涓婁紶骞跺彂閫� FILE 娑堟伅锛沺ayload 鐢� mediaTypeHandlers[FILE] 鑷姩鎷� url + name + size */
+async function uploadAndSendFile(file: File) {
+  // 鏂囦欢鍚嶅彇鏈�鍚庝竴涓� "." 涔嬪悗鐨勯儴鍒嗭紱鏃犲悗缂� / 绌哄瓧绗︿覆閮芥寜"鏈煡"鏀捐
+  const extension = file.name.split('.').pop()?.toLowerCase() ?? ''
+  if (extension && DANGEROUS_FILE_EXTENSIONS.includes(extension)) {
+    message.warning(`涓嶅厑璁稿彂閫� .${extension} 绫诲瀷鏂囦欢`)
+    return
+  }
+  const context = prepareMediaUpload()
+  if (!context) {
+    return
+  }
+  await uploadAndSendMedia({
+    file,
+    type: ImContentType.FILE,
+    quote: context.quote,
+    conversation: context.conversation
+  })
+}
+
+/** 鍥剧墖閫夊畬鍗充笂浼� + 鍙戦�� IMAGE 娑堟伅锛堜笉鏀惧叆 editor锛岀敱 useMediaUploader 鎺ョ鍗犱綅 / 杩涘害 / ack锛� */
+async function onImagePicked(e: Event) {
+  const input = e.target as HTMLInputElement
+  const file = input.files?.[0]
+  input.value = ''
+  if (file) {
+    await uploadAndSendImage(file)
+  }
+}
+
+/** 鏂囦欢閫夊畬鍗充笂浼� + 鍙戦�� FILE 娑堟伅锛堟惡甯﹀師濮� name / size 鍏冩暟鎹級 */
+async function onFilePicked(e: Event) {
+  const input = e.target as HTMLInputElement
+  const file = input.files?.[0]
+  input.value = ''
+  if (file) {
+    await uploadAndSendFile(file)
+  }
+}
+
+// ==================== 璇煶 ====================
+const voiceVisible = ref(false)
+/** 鎵撳紑璇煶褰曞埗闈㈡澘锛涗簰鏂ュ叧鎺夎〃鎯呴潰鏉� */
+function openVoice() {
+  voiceVisible.value = true
+  emojiVisible.value = false
+}
+/** 鍙戦�佸綍闊虫秷鎭� */
+async function onVoiceSend(payload: {
+  blob: Blob
+  duration: number
+  extension: string
+  mimeType: string
+}) {
+  const context = prepareMediaUpload()
+  if (!context) {
+    return
+  }
+  const file = new File([payload.blob], `voice-${Date.now()}.${payload.extension}`, {
+    type: payload.mimeType || payload.blob.type
+  })
+  await uploadAndSendMedia({
+    file,
+    type: ImContentType.VOICE,
+    quote: context.quote,
+    conversation: context.conversation,
+    context: { voiceDuration: payload.duration }
+  })
+}
+
+// ==================== 瑙嗛 ====================
+type VideoProbe = {
+  cover?: Blob
+  duration?: number
+  height?: number
+  width?: number
+}
+
+const VIDEO_COVER_MAX_DIM = 720 // 灏侀潰鏈�闀胯竟 cap锛氳亰澶╁垪琛ㄩ噷鐨勮棰戝皝闈㈡病蹇呰鍘熻棰戝垎杈ㄧ巼锛�4K 鍘熷昂瀵� jpeg 1-3MB 澶氮璐�
+
+/**
+ * 鍔犺浇瑙嗛鏈湴棰勮锛屼竴娆℃�ф嬁鍒� metadata锛坉uration / 瀹介珮锛�+ 棣栧抚灏侀潰 blob
+ *
+ * 涓�涓� video 鍏冪礌涓蹭袱浠朵簨鏄负浜嗛伩鍏嶉噸澶� decode锛歮etadata 瑙e畬鍚庣洿鎺� seek 棣栧抚鍐嶆埅鍥俱��
+ * 鎴浘澶辫触涓嶆姏寮傚父锛屽彧璁� cover 缂哄け锛屼繚璇佷富娴佺▼浠嶈兘涓婁紶瑙嗛鏈綋銆�
+ *
+ * finally 閲屾樉寮忔柇寮曠敤鏄洜涓猴細浠� revokeObjectURL 涓嶈冻浠ヨ video decoder 绔嬪嵆閲婃斁锛�
+ * 閮ㄥ垎娴忚鍣ㄧ増鏈笂 4K 瑙嗛瑙g爜 buffer 鍙粸鐣欐暟鍗� MB 鍑犵鍒板崄鍑犵锛岃繛鍙戝嚑鏉′細绱鏀惧ぇ銆�
+ */
+async function probeVideoFile(file: File): Promise<VideoProbe> {
+  // 1. 鍑嗗绂诲睆 video
+  // 1.1 muted + preload=metadata锛氬彧涓嬭浇鏂囦欢澶达紝涓嶉鍔犺浇鏁存潯娴�
+  const objectUrl = URL.createObjectURL(file)
+  const video = document.createElement('video')
+  video.preload = 'metadata'
+  video.muted = true
+  video.src = objectUrl
+  try {
+    // 1.2 绛� metadata 鍔犺浇锛氳В鍑� duration / 瀹介珮鎵嶆湁 seek + 鎴浘鐨勪緷鎹�
+    await new Promise<void>((resolve, reject) => {
+      video.addEventListener('loadedmetadata', () => resolve(), { once: true })
+      video.addEventListener('error', () => reject(new Error('video metadata load error')), {
+        once: true
+      })
+    })
+    // 1.3 鎶藉厓淇℃伅锛歞uration 鍋舵湁 NaN锛堟瀬灏戞暟鎹熷潖鏂囦欢锛夛紝杞鐞嗕负 undefined
+    const meta = {
+      duration: Number.isFinite(video.duration) ? Math.round(video.duration) : undefined,
+      width: video.videoWidth || undefined,
+      height: video.videoHeight || undefined
+    }
+
+    // 2. 鎴甯у皝闈紙鐙珛 try锛氬け璐ヤ粎闄嶇骇 cover 涓虹┖锛屼笉褰卞搷 meta锛�
+    let cover: Blob | undefined
+    try {
+      // 2.1 绠� seek 鏃堕棿锛�0.1s 閬垮紑甯歌鐨勭函榛戦甯э紱鏃堕暱 < 0.2s 鐨勬瀬鐭棰戦��鍖栦负 0
+      const seekTo = video.duration > 0.2 ? 0.1 : 0
+      // 2.2 seek + 3s 瓒呮椂锛歝urrentTime 璁句负褰撳墠鍊硷紙璀宸茬粡鏄� 0 鐨勬瀬鐭棰戯級
+      //     閮ㄥ垎娴忚鍣ㄤ笉瑙﹀彂 onseeked锛宲romise 浼氫竴鐩� pending 鍗℃鏁存潯閾捐矾
+      await new Promise<void>((resolve, reject) => {
+        const timer = setTimeout(() => reject(new Error('video seek timeout')), 3000)
+        video.addEventListener('seeked', () => {
+          clearTimeout(timer)
+          resolve()
+        }, { once: true })
+        video.addEventListener(
+          'error',
+          () => {
+            clearTimeout(timer)
+            reject(new Error('video seek error'))
+          },
+          { once: true }
+        )
+        video.currentTime = seekTo
+      })
+      // 2.3 绂诲睆 canvas 绛夋瘮缂╂斁锛氶暱杈� cap 720锛圴IDEO_COVER_MAX_DIM锛�
+      const canvas = document.createElement('canvas')
+      const ratio = Math.min(1, VIDEO_COVER_MAX_DIM / Math.max(video.videoWidth, video.videoHeight))
+      canvas.width = Math.round(video.videoWidth * ratio)
+      canvas.height = Math.round(video.videoHeight * ratio)
+      const context = canvas.getContext('2d')
+      if (context && canvas.width && canvas.height) {
+        // 2.4 褰撳墠甯х粯鍒� canvas 鈫� toBlob 鎷� jpeg锛�0.8 璐ㄩ噺鏄亰澶╁皝闈㈠父鐢ㄧ敎鐐�
+        context.drawImage(video, 0, 0, canvas.width, canvas.height)
+        cover =
+          (await new Promise<Blob | null>((resolve) =>
+            canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.8)
+          )) ?? undefined
+        // 2.5 鎻愬墠閲婃斁 canvas backing store锛�4K 鍘熷昂瀵� 33MB锛夛紝鍒瓑 GC
+        canvas.width = 0
+        canvas.height = 0
+      }
+    } catch (error) {
+      console.warn('[IM] 瑙嗛灏侀潰鎴彇澶辫触', error)
+    }
+    return { ...meta, cover }
+  } finally {
+    // 3. 鏄惧紡閲婃斁 video 璧勬簮
+    // 3.1 revoke 鏈湴 objectUrl
+    URL.revokeObjectURL(objectUrl)
+    // 3.2 瑙﹀彂 unload锛岃 decoder buffer 绔嬪嵆閲婃斁
+    video.removeAttribute('src')
+    video.load()
+  }
+}
+
+/**
+ * 涓婁紶骞跺彂閫� VIDEO 娑堟伅
+ *
+ * 1. 绔嬪嵆鍗犱綅锛歜lob URL 鏃㈠綋 video src 涔熷綋 poster锛屾祻瑙堝櫒浼氭嬁棣栧抚缁樺埗 cover锛泂tatus=SENDING + 杩涘害鏉�
+ * 2. probe 涓庤棰戜笂浼犲悓姝ヨ捣璺戯紱灏侀潰涓婁紶绛� probe 鍑� cover 鍚庝笌瑙嗛涓婁紶绔為��
+ *    锛坧robe 瑙g爜 + 灏侀潰涓婁紶閫氬父琚棰戜笂浼犳椂闀垮畬鍏ㄩ伄钄斤紝浣撴劅鑺傜渷鍑犵櫨 ms 璧锋锛�
+ * 3. 瑙嗛鏈綋涓婁紶蹇呴』鎴愬姛锛屾嬁涓嶅埌 url 灏辨妸鍗犱綅缃� FAILED锛涘皝闈㈡槸閿︿笂娣昏姳锛屽け璐ヤ粎鏃ュ織
+ * 4. 瑙嗛閾捐矾鑰楁椂闀匡紝涓婁紶鏈熼棿鐢ㄦ埛鍒囦細璇濆垯鏀惧純鍙戦�侊紙閬垮厤钀藉埌閿欒浼氳瘽閲岋級锛涘垏璧板啀鍒囧洖鏉ヤ笉绠楀彉鍖栵紙key 浠嶇浉绛夛級
+ */
+async function uploadAndSendVideo(file: File) {
+  if (!ensureMediaSizeWithinLimit(file, ImContentType.VIDEO, message.warning)) {
+    return
+  }
+  const context = prepareMediaUpload()
+  if (!context) {
+    return
+  }
+  const { conversation } = context
+  const replyQuote = context.quote
+  const startKey = getConversationKey(conversation)
+
+  // 1. 绔嬪嵆鍗犱綅锛歶rl 璧� blob 璁� <video src> 鎷夐瀛楄妭娓叉煋锛沜overUrl 涓嶈 blob
+  //    锛�<video poster> 鏈熷緟鍥剧墖璧勬簮锛屼紶 video blob 鍦ㄩ儴鍒嗘祻瑙堝櫒浼氶��鍖栨垚榛戝簳锛屼笉鏄ǔ瀹氳涓猴級
+  //    cover 绛� probe 寮傛鍑虹湡瀹� URL 鍚庣敱 commit 闃舵涓�璧� patch锛沖localFile 鐣� file 渚涘け璐ラ噸璇�
+  //    payload 鎷艰璧� mediaTypeHandlers[VIDEO].build 涓� commit 闃舵鍏变韩鍚屼竴浠介�昏緫
+  const videoHandler = requireMediaHandler(ImContentType.VIDEO)
+  const buildPlaceholderContent = (blobUrl: string): string =>
+    serializeMessage(withQuotePayload(videoHandler.build(file, blobUrl, {}), replyQuote))
+  const { clientMessageId } = insertMediaPlaceholder({
+    file,
+    type: ImContentType.VIDEO,
+    conversation,
+    buildContent: buildPlaceholderContent
+  })
+
+  // 2. 涓夎矾骞惰璧疯窇锛坧robe 涓庝袱鏉′笂浼犳棤渚濊禆锛屽皝闈笂浼犵瓑 probe 鍑� cover 鍚庣珛鍗虫帴鍔涳級
+  // 2.1 瑙嗛鏈綋涓婁紶锛歛sync IIFE 鍖呬竴灞傝 await 鏄惧紡鍙锛坙int 涓嶅啀璇垽 floating promise锛夛紝
+  //     澶辫触鍏滃簳涓� url=undefined锛岀敱 step 3 鎷夸笉鍒� url 鏃舵敹灏�
+  const videoUploadPromise: Promise<string | undefined> = (async () => {
+    try {
+      return await uploadFile(
+        { file },
+        createUploadProgressHandler(conversation, clientMessageId)
+      )
+    } catch (error) {
+      console.warn('[IM] 瑙嗛鏈綋涓婁紶澶辫触', error)
+      return undefined
+    }
+  })()
+  // 2.2 probe 鎷垮厓淇℃伅 + 灏侀潰 blob锛氳В鐮佸け璐ラ檷绾т负绌� probe锛屼笉闃绘柇瑙嗛涓婁紶
+  const probePromise = probeVideoFile(file).catch((error): VideoProbe => {
+    console.warn('[IM] 瑙嗛鍏冧俊鎭姞杞藉け璐ワ紝闄嶇骇涓轰粎 url + size', error)
+    return {}
+  })
+  // 2.3 灏侀潰涓婁紶锛氱瓑 probe.cover 鍑烘潵鍚庢帴鍔涜捣璺戯紝涓庤棰戜笂浼犵珵閫燂紱澶辫触闄嶇骇 coverUrl 涓虹┖
+  const coverUploadPromise = probePromise.then(async (probe) => {
+    if (!probe.cover) {
+      return { probe, coverUrl: undefined as string | undefined }
+    }
+    try {
+      const coverUrl = await uploadFile({
+        file: new File([probe.cover], `cover-${Date.now()}.jpg`, { type: 'image/jpeg' })
+      })
+      return { probe, coverUrl }
+    } catch (error) {
+      console.warn('[IM] 瑙嗛灏侀潰涓婁紶澶辫触', error)
+      return { probe, coverUrl: undefined as string | undefined }
+    }
+  })
+
+  // 3. 鏀跺彛鏍¢獙
+  // 3.1 绛変袱鏉′笂浼犻摼璺眹鍚�
+  const [videoRes, { probe, coverUrl }] = await Promise.all([
+    videoUploadPromise,
+    coverUploadPromise
+  ])
+  // 3.2 瑙嗛鏈綋娌� url锛氬崰浣嶇疆 FAILED锛岃鐢ㄦ埛鍐冲畾閲嶈瘯 / 鍒犻櫎锛坃localFile 鍦ㄥ唴瀛橀噷锛�
+  const url = videoRes
+  if (!url) {
+    markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
+    return
+  }
+  if (!isOpenableUrl(url)) {
+    console.warn('[IM] 瑙嗛涓婁紶杩斿洖浜嗕笉鏀寔鎵撳紑鐨� URL', { url })
+    message.warning('涓婁紶杩斿洖鐨勮棰戝湴鍧�涓嶆敮鎸佹墦寮�')
+    markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
+    return
+  }
+  const safeCoverUrl = coverUrl && isOpenableUrl(coverUrl) ? coverUrl : undefined
+  if (coverUrl && !safeCoverUrl) {
+    console.warn('[IM] 瑙嗛灏侀潰涓婁紶杩斿洖浜嗕笉鏀寔鎵撳紑鐨� URL', { coverUrl })
+  }
+  // 3.3 涓婁紶鍚庝細璇濇牎楠� + muteOverlay 澶嶆煡锛堜笌 useMediaUploader.uploadAndSendMedia 鍚屼竴閬擄級
+  if (
+    !verifyMediaUploadStillAllowed(conversation, startKey, ImContentType.VIDEO, clientMessageId)
+  ) {
+    return
+  }
+
+  // 4. 鎷肩湡瀹� VideoMessage payload锛宲atch 杩涘崰浣� + 璧� sendRaw 澶嶇敤鍗犱綅鍙戦��
+  const realContent = serializeMessage(
+    withQuotePayload(
+      videoHandler.build(file, url, {
+        videoProbe: { duration: probe.duration, width: probe.width, height: probe.height },
+        videoCoverUrl: safeCoverUrl
+      }),
+      replyQuote
+    )
+  )
+  await commitMediaPlaceholder({
+    type: ImContentType.VIDEO,
+    conversation,
+    clientMessageId,
+    realContent
+  })
+}
+
+/** 瑙嗛閫夊畬鍗充笂浼� + 鍙戦�� VIDEO 娑堟伅锛堜笉鏀惧叆 editor锛岀嫭绔嬮摼璺細probe + 鍙屼笂浼狅紝鏈�缁堣蛋 commitMediaPlaceholder 鏀跺熬锛� */
+async function onVideoPicked(e: Event) {
+  const input = e.target as HTMLInputElement
+  const file = input.files?.[0]
+  input.value = ''
+  if (file) {
+    await uploadAndSendVideo(file)
+  }
+}
+</script>
+
+<template>
+  <!--
+    澶栧眰搴曡壊涓庢秷鎭祦锛坆g-color-page锛変繚鎸佷竴鑷达紝璁�"娑堟伅 鈫� 杈撳叆"鏃犺壊宸繃娓★紱
+    padding 缁欏唴灞傜櫧鍗$墖鍛煎惛绌洪棿锛屽崱鐗囪嚜甯﹁竟妗嗗氨澶熷尯鍒嗚緭鍏ュ尯锛屼笉鍐嶉渶瑕佷竴鏉� border-t
+  -->
+  <div class="relative bg-[var(--ant-color-bg-layout)] px-3 pt-2 pb-3">
+    <!-- 绂佽█ / 灏佺瑕嗙洊灞傦細浼樺厛绾� 灏佺 > 鍏ㄧ兢绂佽█ > 鎴愬憳绂佽█ -->
+    <div
+      v-if="muteOverlay"
+      class="message-input__mute-overlay absolute top-2 right-3 bottom-3 left-3 z-20 flex items-center justify-center gap-2 rounded-lg border border-solid text-sm"
+      :class="
+        muteOverlay.icon === 'ant-design:stop-outlined'
+          ? 'message-input__mute-overlay--error'
+          : 'message-input__mute-overlay--warning'
+      "
+    >
+      <Icon :icon="muteOverlay.icon" :size="18" />
+      <span>{{ muteOverlay.text }}</span>
+    </div>
+    <!-- 鍐呭眰鐧借壊鍦嗚鍗$墖 = editor + 宸ュ叿鏍忥紱border + rounded 妯℃嫙寰俊銆岃緭鍏ユ銆嶈竟鐣� -->
+    <div
+      class="relative flex flex-col bg-[var(--ant-color-bg-container)] rounded-lg border border-solid border-[var(--im-border-color-lighter)]"
+    >
+      <!--
+        杈撳叆鍖哄湪涓婏細contenteditable div锛堝彇浠� textarea锛屽榻愬井淇� PC锛氳緭鍏ュ尯鍦ㄤ笂锛屾搷浣滃湪涓嬶級
+        - 璁� @ 娴眰鑳芥嬁鍒扮湡瀹炲厜鏍� rect锛坱extarea 鎷夸笉鍒帮級
+        - 璁� @ 鎴愬憳浠� <span data-id> token 鑺傜偣瀛樺湪锛屽垹 token 鍗冲垹 id锛岄伩鍏� stale atUserIds
+        - placeholder 閫氳繃 data-empty + ::before 妯℃嫙锛坈ontenteditable 娌℃湁鍘熺敓 placeholder锛�
+      -->
+      <div
+        ref="editorRef"
+        class="message-input__editor relative min-h-[120px] max-h-[200px] overflow-y-auto py-3.5 px-4 text-sm leading-normal outline-none whitespace-pre-wrap break-words"
+        contenteditable="true"
+        data-placeholder="鎸� Enter 鍙戦�侊紝Shift+Enter 鎹㈣"
+        data-empty=""
+        role="textbox"
+        @keydown="onKeydown"
+        @input="onInput"
+        @scroll.passive="onEditorScroll"
+        @paste.prevent="onPaste"
+      ></div>
+
+      <!-- 寮曠敤棰勮鏉� -->
+      <ReplyPreview
+        v-if="replyTarget"
+        :quote="replyTarget"
+        closable
+        class="mx-3 mb-1.5"
+        @close="clearConversationReplyDraft"
+      />
+
+      <!--
+        搴曢儴宸ュ叿鏍忥細宸︿晶鎿嶄綔鍥炬爣 + 鍙充晶鍙戦�佹寜閽紙瀵归綈寰俊 PC锛氭搷浣滃浘鏍囩粺涓�鏀惧簳閮級
+        - relative 缁� FacePicker 鎻愪緵 absolute 閿氱偣锛宲icker 鐢� bottom-full 鍚戜笂寮瑰嚭
+        - 鍥炬爣缁熶竴 30脳30 鐐瑰嚮鍖猴紙18px icon + p-1.5锛夛紝gap-1 璁╅棿璺濊创鍚堝井淇¤鎰�
+        - border-t 鍦ㄧ紪杈戝尯涓庡伐鍏锋爮涔嬮棿鐢讳竴鏉′笌 card 杈规鍚岃壊鐨勭粏绾�
+      -->
+      <div
+        class="relative flex items-center justify-between gap-2 px-3 py-2 border-t border-t-solid border-[var(--im-border-color-lighter)]"
+      >
+        <div class="flex items-center gap-1">
+          <!--
+            鎵�鏈� icon 缁熶竴璧� Iconify锛坅nt-design outlined 绯诲垪锛夛細
+            - 瑙嗚椋庢牸鏇存帴杩戝井淇� PC锛堢嚎鎬с�佸渾瑙掞紝姣� Element Plus 鍐呯疆鐨勬洿杞婚噺锛�
+            - 绗戣劯 / 鍥剧墖 / 鏂囦欢澶� / 楹﹀厠椋� 鍚屾簮锛岄伩鍏嶄竴涓蛋 ep 涓�涓蛋 antd 瑙嗚鍓茶
+            - 澶栧眰 span 澶嶇敤 .message-input__tool 鐨� padding / hover 鏍峰紡锛宻coped CSS 鐨� :deep(svg) 浠嶈兘鍛戒腑
+          -->
+          <Tooltip title="琛ㄦ儏" placement="top">
+            <span
+              class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]"
+              @click.stop="toggleEmoji"
+            >
+              <Icon icon="ant-design:smile-outlined" :size="18" />
+            </span>
+          </Tooltip>
+          <Tooltip title="鍙戦�佸浘鐗�" placement="top">
+            <span
+              class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]"
+              @click="imageInputRef?.click()"
+            >
+              <Icon icon="ant-design:picture-outlined" :size="18" />
+            </span>
+          </Tooltip>
+          <Tooltip title="鍙戦�佹枃浠�" placement="top">
+            <span
+              class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]"
+              @click="fileInputRef?.click()"
+            >
+              <Icon icon="ant-design:folder-outlined" :size="18" />
+            </span>
+          </Tooltip>
+          <Tooltip title="璇煶娑堟伅" placement="top">
+            <span
+              class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]"
+              @click.stop="openVoice"
+            >
+              <Icon icon="ant-design:audio-outlined" :size="18" />
+            </span>
+          </Tooltip>
+          <Tooltip title="鍙戦�佽棰�" placement="top">
+            <span
+              class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]"
+              @click="videoInputRef?.click()"
+            >
+              <Icon icon="ant-design:video-camera-outlined" :size="18" />
+            </span>
+          </Tooltip>
+        </div>
+
+        <!-- 缇よ亰 + 缇ゅ凡璇诲紑鍚細鍙戦�佹寜閽� + 鈻� 涓嬫媺鑿滃崟锛堢偣涓绘寜閽櫘閫氬彂閫� / 鐐� 鈻� 閫夈�屽彂閫佸洖鎵ф秷鎭�嶏級锛屽榻愬井淇� PC -->
+        <!-- 娉ㄦ剰锛歛nt-design-vue 鐨勫垎瑁傛寜閽槸 DropdownButton锛堥潪 Dropdown 鐨� split-button 灞炴�э紝閭f槸 Element Plus 鍐欐硶锛� -->
+        <DropdownButton
+          v-if="isGroup && MESSAGE_GROUP_READ_ENABLED"
+          type="primary"
+          :disabled="!canSend"
+          @click="handleSend()"
+        >
+          鍙� 閫�
+          <template #overlay>
+            <Menu @click="handleSendMenuClick">
+              <Menu.Item key="receipt">鍙戦�佸洖鎵ф秷鎭�</Menu.Item>
+            </Menu>
+          </template>
+        </DropdownButton>
+        <!-- 绉佽亰鎴栫兢宸茶鍏抽棴锛氭櫘閫氬彂閫佹寜閽紙鏃犵兢鍥炴墽鍏ュ彛锛� -->
+        <Button v-else type="primary" :disabled="!canSend" @click="handleSend()">
+          鍙� 閫�
+        </Button>
+
+        <!-- 琛ㄦ儏闈㈡澘锛歜ottom-full 璁� picker 涓嬫部璐村伐鍏锋爮椤堕儴锛屽悜涓婂脊鍑猴紙瀵归綈宸ュ叿鏍忓乏渚ч鍥炬爣锛� -->
+        <FacePicker
+          v-model:visible="emojiVisible"
+          class="bottom-full left-3 mb-2"
+          @select-emoji="insertText"
+          @select-face="onSelectFace"
+        />
+
+        <!-- 璇煶褰曞埗闈㈡澘锛氫笌琛ㄦ儏闈㈡澘鍚屽宸ュ叿鏍忥紝bottom-full 鍚戜笂寮瑰嚭锛岄伩鍏嶇瑙﹀彂鐨勯害鍏嬮鍥炬爣杩囪繙 -->
+        <VoiceRecorder v-model="voiceVisible" class="bottom-full left-3 mb-2" @send="onVoiceSend" />
+      </div>
+    </div>
+
+    <!-- @ 閫夋嫨娴眰锛氱兢鑱婃墠鍚敤 -->
+    <MentionPicker
+      ref="mentionRef"
+      v-model:visible="mentionVisible"
+      :position="mentionPosition"
+      :members="groupMembers"
+      :search-text="mentionSearchText"
+      :can-at-all="canAtAll"
+      @select="onMentionSelect"
+    />
+
+    <!-- 闅愯棌鐨勬枃浠堕�夋嫨鍣� -->
+    <input ref="imageInputRef" type="file" accept="image/*" hidden @change="onImagePicked" />
+    <input ref="fileInputRef" type="file" hidden @change="onFilePicked" />
+    <input ref="videoInputRef" type="file" accept="video/*" hidden @change="onVideoPicked" />
+  </div>
+</template>
+
+<style scoped>
+/* el-icon 鍏ㄥ眬瑙勫垯 .el-icon{color:var(--color,inherit); font-size:inherit; width:1em; height:1em} 浼樺厛绾ф洿楂橈紝
+   鐢ㄥ瓧闈㈤�夋嫨鍣� + !important 閿佹锛涢鑹插彇 Element Plus 涓婚鍙橀噺锛屾殫鑹茶嚜鍔ㄥ垏鍒版祬鐏� */
+.message-input__tool,
+.message-input__tool:deep(svg) {
+  font-size: 18px !important;
+  color: var(--ant-color-text) !important;
+  fill: currentColor !important;
+}
+.message-input__tool:hover,
+.message-input__tool:hover:deep(svg) {
+  color: var(--ant-color-primary) !important;
+}
+
+.message-input__mute-overlay--warning {
+  color: #d48806;
+  background: #fff7e6;
+  border-color: #ffd591;
+}
+
+.message-input__mute-overlay--error {
+  color: #cf1322;
+  background: #fff1f0;
+  border-color: #ffa39e;
+}
+
+/* 娉ㄦ剰锛氬繀椤绘妸 .dark 涓庣洰鏍囩被鏁翠綋鏀捐繘 :global()锛屽惁鍒� Vue scoped 缂栬瘧浼氭妸
+   `:global(.dark) .xxx` 閿欒濉岀缉鎴愯8 `.dark`锛屽鑷磋繖浜涢鑹茶鐩存帴鍒峰埌 <html> 涓�
+   锛堟殫鑹蹭笅鏁撮〉鍙戠孩/鍙戞殫榛勶級銆侭EM 绫诲悕鍞竴锛屾暣浣� global 涓嶄細璇激鍏跺畠鍏冪礌銆� */
+:global(.dark .message-input__mute-overlay--warning) {
+  color: #ffd666;
+  background: rgb(77 56 21 / 88%);
+  border-color: #ad6800;
+}
+
+:global(.dark .message-input__mute-overlay--error) {
+  color: #ff7875;
+  background: rgb(91 33 33 / 88%);
+  border-color: #a8071a;
+}
+
+/* 鐢� data-empty 鑰岄潪 :empty锛氭祻瑙堝櫒鍦ㄥ垹绌哄悗浼氱暀涓� <br>锛�:empty 涓嶅懡涓紱data-empty 鐢� syncEditorState 缁存姢 */
+.message-input__editor[data-empty]::before {
+  content: attr(data-placeholder);
+  color: var(--ant-color-text-placeholder);
+  pointer-events: none;
+  position: absolute;
+}
+
+/* @ token 璧颁富鑹查珮浜紱contenteditable=false 璁� backspace 鏁存鍒犺�屼笉鏄�愬瓧绗� */
+.message-input__editor :deep(.mention-token) {
+  color: var(--ant-color-primary);
+}
+</style>

--
Gitblit v1.9.3