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/store/rtcStore.ts |  450 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 450 insertions(+), 0 deletions(-)

diff --git a/src/views/im/home/store/rtcStore.ts b/src/views/im/home/store/rtcStore.ts
new file mode 100644
index 0000000..46a98b9
--- /dev/null
+++ b/src/views/im/home/store/rtcStore.ts
@@ -0,0 +1,450 @@
+import type { ImRtcApi } from '#/api/im/rtc'
+
+import { computed, ref } from 'vue'
+
+import { defineStore } from 'pinia'
+
+import { getCurrentUserId } from '#/views/im/utils/auth'
+
+import {
+  ImConversationType,
+  type ImRtcCallEndReasonValue,
+  ImRtcCallStage,
+  type ImRtcCallStageValue,
+  ImRtcCallStatus,
+  type ImRtcParticipantStatusValue
+} from '../../utils/constants'
+import { useFriendStore } from './friendStore'
+import { useGroupStore } from './groupStore'
+
+type GroupActiveCallCache = {
+  participantsLoaded?: boolean // 鏄惁宸叉媺鍙栧畬鏁村弬涓庤�呭垪琛�
+} & ImRtcApi.RtcGroupCallRespVO
+
+// RTC_CALL 閫氳瘽淇′护杞借嵎锛涙寜 status 鍖哄垎瀛愮被鍨嬭涔�
+export interface ImRtcCallNotification {
+  status: ImRtcParticipantStatusValue
+  room: string
+  conversationType: number
+  mediaType: number
+  groupId?: number
+  // INVITE 涓撳睘锛氳鍙帴閫氶渶瑕佺殑 LiveKit 杩炴帴鍙傛暟 + 涓诲彨灞曠ず淇℃伅
+  livekitUrl?: string
+  token?: string
+  inviterUserId?: number
+  inviterNickname?: string
+  inviterAvatar?: string
+  // INVITE 涓撳睘锛氭湰娆¤閭�璇蜂汉鍒楄〃锛涘寘鍚敹浠朵汉鑷韩锛屽墠绔潵鐢靛皬鏉℃寜闇�杩囨护灞曠ず銆岄個璇风殑鍏朵粬浜恒��
+  inviteeIds?: number[]
+  // REJECT 涓撳睘锛氭搷浣滆�呭睍绀轰俊鎭紙鍏跺畠瀛愮被鍨嬭蛋 RTC_CALL_END锛�
+  operatorUserId?: number
+  operatorNickname?: string
+  operatorAvatar?: string
+}
+
+// RTC_PARTICIPANT_CONNECTED 閫氳瘽鍙備笌鑰呭姞鍏ヨ浇鑽凤紙LiveKit webhook 杞帹锛�
+export interface ImRtcParticipantConnectedNotification {
+  room: string
+  userId: number
+  conversationType: number
+  groupId?: number
+  // 缇よ亰鍦烘櫙闈為個璇锋垚鍛橀娆″~鍏呰兌鍥婃潯鐢�
+  mediaType?: number
+  inviterUserId?: number
+}
+
+// RTC_PARTICIPANT_DISCONNECTED 閫氳瘽鍙備笌鑰呯寮�杞借嵎锛圠iveKit webhook 杞帹锛�
+export interface ImRtcParticipantDisconnectedNotification {
+  room: string
+  userId: number
+  conversationType: number
+  groupId?: number
+}
+
+// RTC_CALL_END 閫氳瘽缁撴潫杞借嵎锛堝叆娑堟伅娴侊紱绉佽亰娓叉煋娑堟伅姘旀场锛岀兢鑱婃覆鏌撶郴缁熸彁绀鸿锛�
+export interface ImRtcCallEndNotification {
+  room: string
+  conversationType: number
+  mediaType: number
+  endReason: ImRtcCallEndReasonValue
+  durationSeconds?: number
+  // 鎿嶄綔鑰呰仛鍚堝瓧娈碉細HANGUP/CANCEL/REJECT 瑙﹀彂浜猴紱webhook 鍏滃簳涓� null
+  operatorUserId?: number
+  operatorNickname?: string
+  operatorAvatar?: string
+}
+
+export const useRtcStore = defineStore('imRtc', () => {
+  /** 褰撳墠闃舵 */
+  const stage = ref<ImRtcCallStageValue>(ImRtcCallStage.IDLE)
+  /** 褰撳墠閫氳瘽锛沬nvite / accept / refreshToken 鎷垮埌鐨勫畬鏁翠俊鎭� */
+  const call = ref<ImRtcApi.RtcCallRespVO | null>(null)
+  /** 鏉ョ數杞借嵎锛涗粎 INCOMING 闃舵浣跨敤锛泂tatus 鍥哄畾 INVITING锛屽叾瀹冨瓧娈� INVITE 涓撳睘 */
+  const incomingPayload = ref<ImRtcCallNotification | null>(null)
+  /** 杩涘叆 RUNNING 鐨勬椂闂存埑锛涚敤浜庨�氳瘽鏃堕暱灞曠ず锛況eset 鏃舵竻闆� */
+  const startedAt = ref(0)
+
+  /** 鏄惁澶勪簬閫氳瘽鐩稿叧闃舵 */
+  const isActive = computed(() => stage.value !== ImRtcCallStage.IDLE)
+
+  /**
+   * 瀵圭灞曠ず鍚嶏紱鎸夐樁娈� + 浼氳瘽绫诲瀷鍒嗘敮锛�
+   * INCOMING 鍙栨潵鐢佃浇鑽风殑 inviterNickname锛涚兢閫氳瘽鍙栫兢鍚嶏紱绉佽亰鏌� friendStore 鍙嶆煡瀵圭 userId
+   */
+  const peerNickname = computed<string>(() => {
+    if (stage.value === ImRtcCallStage.INCOMING) {
+      return incomingPayload.value?.inviterNickname || ''
+    }
+    const c = call.value
+    if (!c) return ''
+    if (c.conversationType === ImConversationType.GROUP) {
+      return useGroupStore().getGroup(c.groupId ?? 0)?.name || ''
+    }
+    const peerUserId = resolvePrivatePeerUserId(c)
+    return (peerUserId && useFriendStore().getFriend(peerUserId)?.nickname) || ''
+  })
+
+  /** 瀵圭澶村儚锛涚瓥鐣ュ悓 peerNickname */
+  const peerAvatar = computed<string>(() => {
+    if (stage.value === ImRtcCallStage.INCOMING) {
+      return incomingPayload.value?.inviterAvatar || ''
+    }
+    const c = call.value
+    if (!c) return ''
+    if (c.conversationType === ImConversationType.GROUP) {
+      return useGroupStore().getGroup(c.groupId ?? 0)?.avatar || ''
+    }
+    const peerUserId = resolvePrivatePeerUserId(c)
+    return (peerUserId && useFriendStore().getFriend(peerUserId)?.avatar) || ''
+  })
+
+  /** 绉佽亰鍦烘櫙瀵圭 userId锛氳嚜宸辨槸涓诲彨鍒欏彇棣栦釜 invitee锛屽惁鍒欏彇 inviter */
+  function resolvePrivatePeerUserId(c: ImRtcApi.RtcCallRespVO): number | undefined {
+    const myId = getCurrentUserId()
+    return c.inviterId === myId ? c.inviteeIds?.[0] : c.inviterId
+  }
+
+  /** 缇ゆ椿璺冮�氳瘽绱㈠紩锛沢roupId -> 缇ら�氳瘽鎽樿锛涚敤浜庣兢鑱婇《閮ㄨ兌鍥婃潯 */
+  const groupActiveCalls = ref<Map<number, GroupActiveCallCache>>(new Map())
+
+  /**
+   * 宸查��鍑� / 宸叉嫆缁濈殑鐢ㄦ埛缂栧彿闆嗗悎锛涚兢閫氳瘽鍦烘櫙鍐� pending 鍗犱綅娓叉煋鏃舵帓闄わ紱
+   * 鏉ユ簮锛氬弬涓庤�呯寮�閫氱煡 + 缇ら�氳瘽鍗曚汉鎷掔粷鐨� operatorUserId锛涢�氳瘽缁撴潫锛坮eset锛夋椂娓呯┖
+   */
+  const leftUserIds = ref<Set<number>>(new Set())
+
+  /** 鏄惁宸茶褰曟煇 userId 宸查��鍑� / 鎷掔粷 */
+  function isUserLeft(userId: number): boolean {
+    return leftUserIds.value.has(userId)
+  }
+
+  /** 鏍囪鏌愪釜 userId 宸查��鍑� / 鎷掔粷锛涚敤浜� pending 鍗犱綅娓叉煋鏃舵帓闄� */
+  function markUserLeft(userId: number) {
+    if (!userId || leftUserIds.value.has(userId)) {
+      return
+    }
+    leftUserIds.value = new Set([...leftUserIds.value, userId])
+  }
+
+  /**
+   * 涓诲彨鍙戣捣閫氳瘽锛涙寜浼氳瘽绫诲瀷 + status 鍐冲畾 stage锛�
+   * 缇ら�氳瘽锛氬彂璧蜂汉鐩存帴杩� RUNNING 澶氫汉鍗$墖瑙嗗浘锛屾埧鍐呭彲鑳藉彧鏈夎嚜宸憋紝绛夊叾浠栦汉闄嗙画鍔犲叆锛�
+   * 绉佽亰锛氭寜 status 璧帮紱RUNNING锛堝凡鍔犲叆宸叉湁閫氳瘽鍦烘櫙锛夆啋 RUNNING锛汣REATED 鈫� INVITING 绛夎鍙帴閫�
+   */
+  function startInviting(data: ImRtcApi.RtcCallRespVO) {
+    call.value = data
+    // 缇ら�氳瘽鍦烘櫙鍐欏叆鏈湴鑳跺泭鏉$紦瀛�
+    syncGroupActiveCall(data)
+    // 鏇存柊 stage 鐘舵��
+    if (data.conversationType === ImConversationType.GROUP) {
+      stage.value = ImRtcCallStage.RUNNING
+      startedAt.value = Date.now()
+      return
+    }
+    const running = data.status === ImRtcCallStatus.RUNNING
+    stage.value = running ? ImRtcCallStage.RUNNING : ImRtcCallStage.INVITING
+    if (running) {
+      startedAt.value = Date.now()
+    }
+  }
+
+  /** 琚彨鏀跺埌鏉ョ數锛涘垏鍒� INCOMING锛涙帴鏀� RTC_CALL(INVITE) payload */
+  function showIncoming(payload: ImRtcCallNotification) {
+    if (isActive.value) {
+      return
+    }
+    incomingPayload.value = payload
+    stage.value = ImRtcCallStage.INCOMING
+    // 鎸� inviter 鍏滃簳棣栨濉厖鑳跺泭鏉�
+    syncGroupActiveCall({
+      conversationType: payload.conversationType,
+      room: payload.room,
+      groupId: payload.groupId,
+      mediaType: payload.mediaType,
+      inviterId: payload.inviterUserId ?? 0,
+      joinedUserIds: payload.inviterUserId ? [payload.inviterUserId] : [],
+      inviteeIds: payload.inviteeIds
+    })
+  }
+
+  /** 杩涘叆閫氳瘽涓樁娈� */
+  function enterRunning(data: ImRtcApi.RtcCallRespVO) {
+    call.value = data
+    // 绂诲紑 INCOMING 闃舵锛涙竻绌烘潵鐢佃浇鑽�
+    incomingPayload.value = null
+    stage.value = ImRtcCallStage.RUNNING
+    startedAt.value = Date.now()
+    // 鎺ラ�氬悗鐢� RespVO 瀹屾暣瑕嗙洊鑳跺泭鏉�
+    syncGroupActiveCall(data)
+  }
+
+  /**
+   * 缇ら�氳瘽鍦烘櫙鍚屾鏈湴 groupActiveCalls 缂撳瓨锛涢潪缇ら�氳瘽鎴栫己 groupId 鐩存帴杩斿洖锛�
+   * 涓嶄緷璧栧悗绔� webhook 鎺ㄩ�佺殑 RTC_PARTICIPANT_CONNECTED 棣栨濉厖锛岄伩鍏嶈兌鍥婃潯鍑虹幇寤惰繜锛�
+   * 琚彨鍦烘櫙閫氱煡杞借嵎鏃� joinedUserIds锛岃皟鐢ㄦ柟鎸変富鍙汉鍏滃簳锛屽悗缁� getActiveCall / 鍙備笌鑰呬簨浠跺埛鏂版垚瀹屾暣鍒楄〃
+   */
+  function syncGroupActiveCall(input: {
+    conversationType: number
+    groupId?: number
+    inviteeIds?: number[]
+    inviterId: number
+    joinedUserIds?: number[]
+    mediaType: number
+    room: string
+  }) {
+    if (input.conversationType !== ImConversationType.GROUP || !input.groupId) {
+      return
+    }
+    // 鍐欏叆鎴栨洿鏂扮兢娲昏穬閫氳瘽缂撳瓨
+    setGroupCall({
+      room: input.room,
+      groupId: input.groupId,
+      mediaType: input.mediaType,
+      inviterId: input.inviterId,
+      joinedUserIds: input.joinedUserIds ?? [],
+      inviteeIds: input.inviteeIds ?? []
+    })
+  }
+
+  /** 閲嶇疆锛涢�氳瘽缁撴潫缁熶竴璋冪敤 */
+  function reset() {
+    stage.value = ImRtcCallStage.IDLE
+    call.value = null
+    incomingPayload.value = null
+    startedAt.value = 0
+    leftUserIds.value = new Set()
+  }
+
+  /** 閫氳瘽涓拷鍔犺閭�璇蜂汉锛涜 participants 缃戞牸鍑虹幇 pending 鍗犱綅銆佽兌鍥婃潯鍚屾鏇存柊 */
+  function appendInvitees(userIds: number[]) {
+    if (!call.value || userIds.length === 0) {
+      return
+    }
+    const existing = call.value.inviteeIds ?? []
+    const merged = [...new Set([...existing, ...userIds])]
+    if (merged.length === existing.length) {
+      return
+    }
+    call.value = { ...call.value, inviteeIds: merged }
+    syncGroupActiveCall(call.value)
+  }
+
+  // ==================== 缇ら�氳瘽鑳跺泭鏉$姸鎬� ====================
+
+  /**
+   * 缇ら�氳瘽寮�濮� / 鐘舵�佸埛鏂帮細鍐欏叆 / 鏇存柊 groupActiveCalls锛涘睍绀虹敤銆岃兌鍥婃潯銆嶏紱
+   * 鎴垮唴鎴愬憳鍚屾浜ょ粰 LiveKit 瀹㈡埛绔簨浠讹紙ParticipantConnected / Disconnected锛夛紱
+   * 鑳跺泭鏉′笉瀹炴椂鍒锋柊 joinedUserIds / inviteeIds锛屽睍寮� / 鍔犲叆鏃跺啀璧� getActiveCall 鎺ュ彛鎷夋渶鏂�
+   */
+  function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO, participantsLoaded?: boolean) {
+    if (!payload?.groupId) {
+      return
+    }
+    useGroupStore().markGroupActiveCallLoaded(payload.groupId)
+    // 娴呮瘮杈冿細room / mediaType / joinedUserIds / inviteeIds 閮芥病鍙樺氨璺宠繃锛岄伩鍏嶄笅娓� watcher 鏃犳剰涔夐噸绠�
+    const existing = groupActiveCalls.value.get(payload.groupId)
+    const nextParticipantsLoaded =
+      participantsLoaded ?? (existing?.room === payload.room && !!existing.participantsLoaded)
+    if (
+      existing &&
+      isSameGroupCall(existing, payload) &&
+      !!existing.participantsLoaded === nextParticipantsLoaded
+    ) {
+      return
+    }
+    const newGroupActiveCalls = new Map(groupActiveCalls.value)
+    newGroupActiveCalls.set(payload.groupId, {
+      ...payload,
+      participantsLoaded: nextParticipantsLoaded
+    })
+    groupActiveCalls.value = newGroupActiveCalls
+  }
+
+  /** 娓呯┖鎸囧畾缇ょ殑閫氳瘽缂撳瓨 */
+  function clearGroupCallCache(groupId?: number) {
+    if (!groupId) {
+      groupActiveCalls.value = new Map()
+      return
+    }
+    const next = new Map(groupActiveCalls.value)
+    next.delete(groupId)
+    groupActiveCalls.value = next
+  }
+
+  /** 鍒ゆ柇缇ら�氳瘽鏄惁宸茶ˉ榻� */
+  function isGroupCallParticipantsLoaded(groupId: number, room?: string): boolean {
+    const call = groupActiveCalls.value.get(groupId)
+    return (
+      !!groupId &&
+      !!room &&
+      !!call &&
+      call.room === room &&
+      !!call.participantsLoaded
+    )
+  }
+
+  /** 涓ゆ潯缇ら�氳瘽鎽樿鍐呭鐩哥瓑锛坮oom / mediaType / inviterId / 涓や釜 userId 鏁扮粍閫愰」鐩哥瓑锛� */
+  function isSameGroupCall(a: ImRtcApi.RtcGroupCallRespVO, b: ImRtcApi.RtcGroupCallRespVO): boolean {
+    if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) {
+      return false
+    }
+    return isSameNumberList(a.joinedUserIds ?? [], b.joinedUserIds ?? []) &&
+      isSameNumberList(a.inviteeIds ?? [], b.inviteeIds ?? [])
+  }
+
+  /** 鍒ゆ柇涓や釜鐢ㄦ埛缂栧彿鍒楄〃鏄惁閫愰」鐩哥瓑 */
+  function isSameNumberList(a: number[], b: number[]): boolean {
+    if (a.length !== b.length) {
+      return false
+    }
+    return a.every((item, index) => item === b[index])
+  }
+
+  /** 缇ら�氳瘽缁撴潫锛氫粠 groupActiveCalls 绉婚櫎锛涜兌鍥婃潯娑堝け */
+  function removeGroupCall(groupId: number, room?: string) {
+    if (!groupId) {
+      return
+    }
+    const existing = groupActiveCalls.value.get(groupId)
+    if (room && existing?.room !== room) {
+      return
+    }
+    clearGroupCallCache(groupId)
+    useGroupStore().markGroupActiveCallLoaded(groupId)
+  }
+
+  /** 鑾峰彇缇ゅ綋鍓嶆椿璺冮�氳瘽锛涚敤浜庤兌鍥婃潯鎸� groupId 鏌ヨ */
+  function getGroupCall(groupId: number): ImRtcApi.RtcGroupCallRespVO | undefined {
+    return groupActiveCalls.value.get(groupId)
+  }
+
+  /** 閫氳瘽鍙備笌鑰呭姞鍏ワ細鎶� userId 鍔犺繘 joinedUserIds锛涚兢鑱婂満鏅棤娲昏穬璁板綍鏃堕娆″~鍏呰兌鍥婃潯 */
+  function applyParticipantConnected(payload: ImRtcParticipantConnectedNotification) {
+    const isGroup = payload.conversationType === ImConversationType.GROUP
+    if (!isGroup || !payload.groupId) {
+      return
+    }
+    // 鑳跺泭鏉℃噿濉厖锛氭湰绔彲鑳藉湪閫氳瘽寮�濮嬪悗鎵嶆墦寮�璇ョ兢浼氳瘽锛屾病鏀跺埌杩� setGroupCall锛�
+    // 姝ゅ鐢ㄥ姞鍏ラ�氱煡寤轰竴鏉℃渶灏忚褰曪紝inviteeIds 鐣欑┖锛屽睍寮� popover / 鍔犲叆鏃跺啀璧� getActiveCall 琛�
+    const existing = groupActiveCalls.value.get(payload.groupId)
+    if (!existing) {
+      setGroupCall({
+        room: payload.room,
+        groupId: payload.groupId,
+        mediaType: payload.mediaType ?? 0,
+        inviterId: payload.inviterUserId ?? 0,
+        joinedUserIds: [payload.userId],
+        inviteeIds: []
+      })
+      return
+    }
+    if (existing.room !== payload.room) {
+      return
+    }
+    const joined = existing.joinedUserIds ?? []
+    if (joined.includes(payload.userId)) {
+      return
+    }
+    setGroupCall({ ...existing, joinedUserIds: [...joined, payload.userId] })
+  }
+
+  /** 閫氳瘽鍙備笌鑰呯寮�锛氫粠 joinedUserIds 绉婚櫎锛涘悓鏃舵爣璁� leftUserIds锛坧ending 鍗犱綅娓叉煋鎺掗櫎锛� */
+  function applyParticipantDisconnected(payload: ImRtcParticipantDisconnectedNotification) {
+    markUserLeft(payload.userId)
+    const isGroup = payload.conversationType === ImConversationType.GROUP
+    if (!isGroup || !payload.groupId) {
+      return
+    }
+    dropFromGroupActiveCall(payload.groupId, payload.room, payload.userId)
+  }
+
+  /** 缇ら�氳瘽鍗曚汉鎷掔粷閭�璇凤細鏍囪 leftUserIds + 浠庤兌鍥婃潯 inviteeIds 绉婚櫎锛堢鑱婃嫆缁濊蛋 RTC_CALL_END锛屼笉鍏ユ湰閫氶亾锛� */
+  function applyParticipantRejected(
+    payload: Pick<ImRtcCallNotification, 'conversationType' | 'groupId' | 'operatorUserId' | 'room'>
+  ) {
+    if (!payload.operatorUserId) {
+      return
+    }
+    markUserLeft(payload.operatorUserId)
+    if (payload.conversationType === ImConversationType.GROUP && payload.groupId) {
+      dropFromGroupActiveCall(payload.groupId, payload.room, payload.operatorUserId)
+    }
+  }
+
+  /** 缇ら�氳瘽鍗曚汉鎸搩瓒呮椂锛涘 banner 鐨勫鐞嗕笌鎷掓帴涓�鑷达紙璇箟鐙珛銆佸疄鐜板叡浜級 */
+  function applyParticipantNoAnswer(
+    payload: Pick<ImRtcCallNotification, 'conversationType' | 'groupId' | 'operatorUserId' | 'room'>
+  ) {
+    applyParticipantRejected(payload)
+  }
+
+  /** 浠庢寚瀹氱兢娲昏穬閫氳瘽鐨� joined / pending 鍒楄〃閲屽悓姝ョЩ闄ゆ煇鐢ㄦ埛锛涚敤浜� disconnect / reject 璁╄兌鍥婃潯涓嶅啀灞曠ず */
+  function dropFromGroupActiveCall(groupId: number, room: string, userId: number) {
+    const existing = groupActiveCalls.value.get(groupId)
+    if (!existing || existing.room !== room) {
+      return
+    }
+    const joined = existing.joinedUserIds ?? []
+    const invitee = existing.inviteeIds ?? []
+    const nextJoined = joined.filter((id) => id !== userId)
+    const nextInvitee = invitee.filter((id) => id !== userId)
+    if (nextJoined.length === joined.length && nextInvitee.length === invitee.length) {
+      return
+    }
+    if (nextJoined.length === 0 && nextInvitee.length === 0) {
+      removeGroupCall(groupId, room)
+      return
+    }
+    setGroupCall({
+      ...existing,
+      joinedUserIds: nextJoined,
+      inviteeIds: nextInvitee
+    })
+  }
+
+  return {
+    stage,
+    call,
+    incomingPayload,
+    peerNickname,
+    peerAvatar,
+    startedAt,
+    isActive,
+    startInviting,
+    showIncoming,
+    enterRunning,
+    reset,
+    appendInvitees,
+    markUserLeft,
+    isUserLeft,
+    setGroupCall,
+    isGroupCallParticipantsLoaded,
+    clearGroupCallCache,
+    removeGroupCall,
+    getGroupCall,
+    applyParticipantConnected,
+    applyParticipantDisconnected,
+    applyParticipantRejected,
+    applyParticipantNoAnswer
+  }
+})

--
Gitblit v1.9.3