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