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/composables/useMessageSender.ts |  337 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 337 insertions(+), 0 deletions(-)

diff --git a/src/views/im/home/composables/useMessageSender.ts b/src/views/im/home/composables/useMessageSender.ts
new file mode 100644
index 0000000..4562128
--- /dev/null
+++ b/src/views/im/home/composables/useMessageSender.ts
@@ -0,0 +1,337 @@
+import type { Conversation, Message } from '../types'
+
+import { readChannelMessages as apiReadChannelMessages } from '#/api/im/message/channel'
+import {
+  readGroupMessages as apiReadGroupMessages,
+  recallGroupMessage as apiRecallGroupMessage,
+  sendGroupMessage as apiSendGroupMessage
+} from '#/api/im/message/group'
+import {
+  getPrivateMaxReadMessageId as apiGetPrivateMaxReadMessageId,
+  readPrivateMessages as apiReadPrivateMessages,
+  recallPrivateMessage as apiRecallPrivateMessage,
+  sendPrivateMessage as apiSendPrivateMessage
+} from '#/api/im/message/private'
+import { getCurrentUserId } from '#/views/im/utils/auth'
+
+import { MESSAGE_GROUP_READ_ENABLED, MESSAGE_PRIVATE_READ_ENABLED } from '../../utils/config'
+import { ImContentType, ImConversationType, ImMessageStatus } from '../../utils/constants'
+import { getClientConversationId } from '../../utils/db'
+import {
+  generateClientMessageId,
+  type QuoteMessage,
+  serializeMessage,
+  type TextMessage,
+  withQuotePayload
+} from '../../utils/message'
+import { useConversationStore } from '../store/conversationStore'
+import { useMessageStore } from '../store/messageStore'
+
+/** 闈炴枃鏈秷鎭殑鎵╁睍閫夐」锛堥�氱敤锛� */
+interface SendExtOptions {
+  atUserIds?: number[] // 缇よ亰 @ 鐨勭敤鎴风紪鍙峰垪琛�
+  receipt?: boolean // 鏄惁闇�瑕佺兢鍥炴墽锛堥粯璁� false锛�
+  targetId?: number // 瑕嗙洊榛樿鐨� targetId
+  /**
+   * 鏄惧紡鎸囧畾鐩爣浼氳瘽锛堣浆鍙� / 鍚嶇墖鎺ㄨ崘鍦烘櫙锛�
+   *
+   * 涓嶄紶鏃堕粯璁ゅ彇 conversationStore.activeConversation锛涗紶鍏ユ椂鎸夋湰鍊煎彂閫� + 涔愯鏇存柊鍒板搴斾細璇濓紝
+   * 涓嶈姹傝浼氳瘽褰撳墠鏄縺娲荤姸鎬侊紙閫傚悎鍙戠粰銆岄潪褰撳墠浼氳瘽銆嶇殑澶氫釜鐩爣锛�
+   */
+  conversation?: Conversation
+  /** 琚紩鐢ㄦ秷鎭紙鍙�夛級锛氬啓杩� content.quote 鐢ㄤ簬涔愯娓叉煋锛屾湇鍔$鎸� quote.messageId 鍙嶆煡閲嶇畻瑕嗙洊 */
+  quote?: QuoteMessage
+  /**
+   * 澶嶇敤宸插瓨鍦ㄧ殑鏈湴鍗犱綅娑堟伅 clientMessageId锛堝獟浣撲笂浼犲満鏅級
+   *
+   * 濯掍綋涓婁紶閾捐矾鍦ㄨ姹傛湇鍔$鍓嶅凡缁� insertMessage 浜嗗崰浣嶏紙甯� blob URL + 杩涘害鏉★級锛�
+   * 杩欓噷璺宠繃 buildLocalMessage / insertMessage锛岀洿鎺ユ嬁杩欎釜 id 璧� ackMessage 鏀跺熬锛岄伩鍏嶉噸澶嶆彃鍏ヤ袱鏉�
+   */
+  existingClientMessageId?: string
+}
+
+/**
+ * 娑堟伅鍙戦�� / 鎾ゅ洖 / 宸茶 缁勫悎寮忛�昏緫
+ *
+ * 璁捐瑕佺偣锛�
+ * 1. 绉佽亰 / 缇よ亰鎺ュ彛绛惧悕瀵圭О锛屾寜 conversation.type 鍒嗘敮璋冨害锛屽樊寮傚湪鍒嗘敮鍐呴儴娑堝寲
+ * 2. 鍙戦�佽蛋銆屼箰瑙傛洿鏂般�嶏細鍏� insertMessage 鍐欏叆 SENDING 鍗犱綅锛岃姹傛垚鍔� ackMessage 鏇存柊涓� NORMAL锛屽け璐ユ洿鏂颁负 FAILED
+ * 3. 鎾ゅ洖涓嶅仛涔愯鏇存柊锛氭湇鍔$閫氳繃 WebSocket RECALL 浜嬩欢鍥炰紶锛岀敱 websocketStore 缁熶竴鏇存柊鐘舵�侊紝閬垮厤缃戠粶澶辫触鍚庝笉鍙洖閫�
+ * 4. 宸茶涓婃姤锛氭湰绔珛鍒绘竻鏈鏁板苟璁板綍鏈湴璇讳綅缃紱鎺ュ彛澶辫触浠呰褰曟棩蹇�
+ */
+export const useMessageSender = () => {
+  const conversationStore = useConversationStore()
+  const messageStore = useMessageStore()
+
+  /** 鏋勯�犳湰鍦颁箰瑙傛秷鎭璞� */
+  const buildLocalMessage = (opts: {
+    atUserIds?: number[]
+    clientMessageId: string
+    content: string
+    targetId: number
+    type: number
+  }): Message => {
+    return {
+      clientMessageId: opts.clientMessageId,
+      type: opts.type,
+      content: opts.content,
+      status: ImMessageStatus.SENDING,
+      sendTime: Date.now(),
+      senderId: getCurrentUserId(),
+      targetId: opts.targetId,
+      selfSend: true,
+      atUserIds: opts.atUserIds
+    }
+  }
+
+  /**
+   * 鍙戦�佷换鎰忕被鍨嬬殑娑堟伅锛堝簳灞傚疄鐜帮級
+   * 1. 鏂囨湰銆佸浘鐗囥�佹枃浠躲�佽闊崇瓑閮借蛋杩欓噷
+   * 2. type / content 鐢辫皟鐢ㄦ柟鏋勯��
+   * 3. 杩斿洖鍊硷細鎴愬姛 true / 澶辫触 false锛堝け璐ユ椂鏈湴鍗犱綅宸叉爣 FAILED锛夛紱鍙傛暟缂哄け绛夋棤娉曞彂閫佺殑鍦烘櫙涔熻繑 false
+   *    杞彂 / 鍚嶇墖鎺ㄨ崘绛夊満鏅寜杩斿洖鍊煎喅瀹氭槸鍚︾户缁悗缁姩浣滐紙濡傛湁鐣欒█鏃朵粎鍦ㄥ悕鐗囨垚鍔熷悗鍐嶅彂鐣欒█锛�
+   */
+  const sendRaw = async (
+    type: number,
+    content: string,
+    options?: SendExtOptions
+  ): Promise<boolean> => {
+    // 1. 鍙傛暟鏍¢獙锛氫紭鍏堢敤鏄惧紡浼犲叆鐨� conversation锛堣浆鍙戝満鏅級锛屽惁鍒欏彇婵�娲讳細璇�
+    const conversation = options?.conversation ?? conversationStore.activeConversation
+    if (!conversation) {
+      return false
+    }
+    const realTarget = options?.targetId || conversation.targetId
+    if (!realTarget) {
+      return false
+    }
+
+    // 2. 鍑嗗 clientMessageId锛氬獟浣撲笂浼犻摼璺湪 step 1 宸茬粡 insertMessage 鍗犱綅锛岃繖閲岀洿鎺ュ鐢� id锛涘叾浣欏満鏅蛋榛樿涔愯鎻掑叆
+    let clientMessageId: string
+    if (options?.existingClientMessageId) {
+      clientMessageId = options.existingClientMessageId
+      // 鍗犱綅鑻ュ凡琚垹闄わ紙涓婁紶鏈熼棿鐢ㄦ埛鍙抽敭鍒犻櫎 / 鎾ゅ洖 / removeMessage 绛夛級鍒欐斁寮冨彂閫侊紝
+      // 鍚﹀垯 sendRaw 浠嶄細鎶婃秷鎭帹鍒版湇鍔$锛屽鑷�"鏈湴鏃犳皵娉� / 瀵规柟鍗存敹鍒颁竴鏉�"
+      const stillExists = messageStore
+        .getMessageList(conversation.type, realTarget)
+        .some((message) => message.clientMessageId === clientMessageId && !message._ackMerging)
+      if (!stillExists) {
+        return false
+      }
+    } else {
+      clientMessageId = generateClientMessageId()
+      const message = buildLocalMessage({
+        clientMessageId,
+        content,
+        targetId: realTarget,
+        type,
+        atUserIds: options?.atUserIds
+      })
+      const conversationInfo = {
+        type: conversation.type,
+        targetId: realTarget,
+        name: conversation.name || String(realTarget),
+        avatar: conversation.avatar || ''
+      }
+      void messageStore.insertMessage(conversationInfo, message).catch(() => undefined)
+    }
+
+    // 3. 鍙戦�佽姹傦細鎸変細璇濈被鍨嬪垎鍙戝埌涓嶅悓鎺ュ彛锛涙垚鍔熷悗 ackMessage 鏇存柊涓� NORMAL锛屽け璐ユ洿鏂颁负 FAILED
+    try {
+      if (conversation.type === ImConversationType.PRIVATE) {
+        const data = await apiSendPrivateMessage({
+          clientMessageId,
+          receiverId: realTarget,
+          type,
+          content
+        })
+        void messageStore
+          .ackMessage(conversation.type, realTarget, clientMessageId, {
+            id: data.id,
+            sendTime: new Date(data.sendTime).getTime(),
+            status: data.status,
+            receiptStatus: data.receiptStatus,
+            content: data.content
+          })
+          .catch(() => undefined)
+      } else if (conversation.type === ImConversationType.GROUP) {
+        const data = await apiSendGroupMessage({
+          clientMessageId,
+          groupId: realTarget,
+          type,
+          content,
+          atUserIds: options?.atUserIds,
+          receipt: options?.receipt
+        })
+        void messageStore
+          .ackMessage(conversation.type, realTarget, clientMessageId, {
+            id: data.id,
+            sendTime: new Date(data.sendTime).getTime(),
+            status: data.status,
+            receiptStatus: data.receiptStatus,
+            readCount: data.readCount,
+            content: data.content
+          })
+          .catch(() => undefined)
+      }
+      return true
+    } catch (error) {
+      console.error('[IM] 娑堟伅鍙戦�佸け璐�', { type, realTarget, clientMessageId }, error)
+      void messageStore
+        .ackMessage(conversation.type, realTarget, clientMessageId, {
+          status: ImMessageStatus.FAILED
+        })
+        .catch(() => undefined)
+      return false
+    }
+  }
+
+  /**
+   * 鍙戦�佹枃鏈秷鎭紙鏈�甯哥敤鐨勫揩鎹峰叆鍙o級锛歮essage-input.vue 鏂囨湰鍥炶溅璧拌繖閲�
+   * 杩斿洖鍊硷細鎴愬姛 true / 澶辫触 false / 绌烘枃鏈� false锛堜笌 sendRaw 瀵归綈锛岃浆鍙戝満鏅寜杩斿洖鍊煎垽鏂級
+   */
+  const send = async (text: string, options?: SendExtOptions): Promise<boolean> => {
+    if (!text.trim()) {
+      return false
+    }
+    const payload = withQuotePayload<TextMessage>({ content: text }, options?.quote)
+    return sendRaw(ImContentType.TEXT, serializeMessage(payload), options)
+  }
+
+  /**
+   * 鎾ゅ洖鏌愭潯娑堟伅
+   * 1. 鏈嶅姟绔細閫氳繃 WebSocket RECALL 浜嬩欢鍥炰紶锛屾湰绔� UI 鐢� websocketStore 缁熶竴鏇存柊
+   * 2. 姝ゅ涓嶅仛涔愯鎾ゅ洖锛岄伩鍏嶇綉缁滃け璐ュ悗鐘舵�佷笉鍙洖閫�
+   */
+  const recall = async (message: Message) => {
+    // 鍙傛暟鏍¢獙锛氭湰鍦板崰浣嶆秷鎭笉鑳芥挙鍥�
+    if (!message.id) {
+      return
+    }
+    const conversation = conversationStore.activeConversation
+    if (!conversation) {
+      return
+    }
+    // 绉佽亰 / 缇よ亰鎺ュ彛绛惧悕涓�鑷达紝鎸変細璇濈被鍨嬪垎鍙�
+    const isPrivate = conversation.type === ImConversationType.PRIVATE
+    try {
+      await (isPrivate ? apiRecallPrivateMessage(message.id) : apiRecallGroupMessage(message.id))
+    } catch (error) {
+      console.error('[IM] 鎾ゅ洖澶辫触', { messageId: message.id, type: conversation.type }, error)
+    }
+  }
+
+  /**
+   * 瑙﹀彂褰撳墠浼氳瘽鐨勫凡璇讳笂鎶ワ紙鍒囦細璇� / 杩涘叆椤甸潰鏃惰皟鐢級
+   * 1. 鏈绔嬪埢娓呮湭璇绘暟骞舵帹杩涜浣嶇疆
+   * 2. 宸茶浣嶇疆鍙栧凡鍔犺浇娑堟伅鍜屼細璇濇湯鏉℃秷鎭殑鏈�澶ф湇鍔$ id
+   */
+  const readActive = async () => {
+    const conversation = conversationStore.activeConversation
+    if (!conversation) {
+      return
+    }
+    let loadedMaxMessageId = 0
+    for (const message of messageStore.getMessages(
+      getClientConversationId(conversation.type, conversation.targetId)
+    )) {
+      if (message.id && message.id > loadedMaxMessageId) {
+        loadedMaxMessageId = message.id
+      }
+    }
+    const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
+    const readReported = conversationStore.isReportedReadPositionCovered(
+      conversation.type,
+      conversation.targetId,
+      maxMessageId
+    )
+    if (readReported) {
+      conversationStore.markConversationRead(conversation.type, conversation.targetId)
+      return
+    }
+    const isPrivate = conversation.type === ImConversationType.PRIVATE
+    const isGroup = conversation.type === ImConversationType.GROUP
+    const isChannel = conversation.type === ImConversationType.CHANNEL
+    // 鏈湴鏍囪宸茶锛氭湭璇绘暟娓呴浂锛圲I 绔嬪埢鍝嶅簲锛�
+    conversationStore.markConversationRead(conversation.type, conversation.targetId, maxMessageId)
+    if (!maxMessageId) {
+      return
+    }
+    // 鎺ュ彛璋冪敤锛氭寜浼氳瘽绫诲瀷鍒嗗彂锛屽苟鎸夊搴斿凡璇诲紑鍏虫帶鍒�
+    if (!isPrivate && !isGroup && !isChannel) {
+      return
+    }
+    if (isPrivate && !MESSAGE_PRIVATE_READ_ENABLED) {
+      return
+    }
+    if (isGroup && !MESSAGE_GROUP_READ_ENABLED) {
+      return
+    }
+    try {
+      if (isPrivate) {
+        await apiReadPrivateMessages(conversation.targetId, maxMessageId)
+      } else if (isGroup) {
+        await apiReadGroupMessages(conversation.targetId, maxMessageId)
+      } else {
+        await apiReadChannelMessages(conversation.targetId, maxMessageId)
+      }
+      conversationStore.markConversationReadReported(
+        conversation.type,
+        conversation.targetId,
+        maxMessageId
+      )
+    } catch (error) {
+      console.error(
+        '[IM] 鏍囪宸茶澶辫触',
+        { type: conversation.type, targetId: conversation.targetId, maxMessageId },
+        error
+      )
+    }
+  }
+
+  /**
+   * 鎷夊彇銆屽鏂瑰凡璇诲埌鎴戝摢鏉℃秷鎭�嶅苟琛ラ綈鏈湴鐘舵��
+   *
+   * 1. 寮ヨˉ绂荤嚎 / 澶氱鏈熼棿閿欒繃鐨� RECEIPT 鎺ㄩ�侊細杩涘叆绉佽亰浼氳瘽鎴栨柇绾块噸杩炲悗璋冧竴娆★紝
+   *    鎶婂鏂� maxReadId 鍚屾鍒版湰鍦版秷鎭� status锛岄伩鍏嶅鏂规槑鏄庤浜嗐�佹湰绔嵈浠嶆樉绀烘湭璇�
+   * 2. 浠呯鑱婁娇鐢細缇よ亰宸茶浣嶇疆鍦ㄦ瘡鏉℃秷鎭殑 readCount / receiptStatus 瀛楁锛岀绾挎媺鍙栬嚜甯﹀洖
+   */
+  const syncPrivateReadStatus = async (peerId: number) => {
+    if (!peerId) {
+      return
+    }
+    // 绉佽亰宸茶鍏抽棴锛氳烦杩囧鏂瑰凡璇讳綅缃悓姝ワ紝閬垮厤鏃犺皳鎺ュ彛璋冪敤
+    if (!MESSAGE_PRIVATE_READ_ENABLED) {
+      return
+    }
+    const cachedMaxReadId = messageStore.getPrivateReadMaxId(peerId)
+    if (cachedMaxReadId !== undefined) {
+      if (cachedMaxReadId > 0) {
+        messageStore.applyMessageReadReceipt({
+          conversationType: ImConversationType.PRIVATE,
+          targetId: peerId,
+          privateReadMaxId: cachedMaxReadId
+        })
+      }
+      return
+    }
+    try {
+      // 鎷夊彇瀵规柟宸茶鍒扮殑鏈�澶ф秷鎭� id
+      const maxReadId = await apiGetPrivateMaxReadMessageId(peerId)
+      messageStore.updatePrivateReadMaxId(peerId, maxReadId)
+      if (!maxReadId) {
+        return
+      }
+      // applyMessageReadReceipt 鍐呴儴鎶� 鈮� maxReadId 鐨勬湰绔秷鎭洖鎵ф洿鏂颁负 DONE
+      messageStore.applyMessageReadReceipt({
+        conversationType: ImConversationType.PRIVATE,
+        targetId: peerId,
+        privateReadMaxId: maxReadId
+      })
+    } catch (error) {
+      console.warn('[IM] 鎷夊彇瀵规柟宸茶浣嶇疆澶辫触', { peerId }, error)
+    }
+  }
+
+  return { send, sendRaw, recall, readActive, syncPrivateReadStatus }
+}

--
Gitblit v1.9.3