gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/store/groupStore.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1017 @@
import type { Group, GroupDO, GroupMember, Message } from '../types'
import type { ImGroupApi } from '#/api/im/group'
import type { ImGroupMemberApi } from '#/api/im/group/member'
import { CommonStatusEnum } from '#/packages/constants/src'
import { acceptHMRUpdate, defineStore } from 'pinia'
import { getGroup as apiGetGroup, getMyGroupList as apiGetMyGroupList } from '#/api/im/group'
import { getGroupMember as apiGetGroupMember, getGroupMemberList as apiGetGroupMemberList, updateGroupMember as apiUpdateGroupMember } from '#/api/im/group/member'
import { getCurrentUserId } from '#/views/im/utils/auth'
import {
  ImContentType,
  ImConversationType,
  ImGroupMemberRole,
  ImMessageStatus
} from '../../utils/constants'
import { getDb } from '../../utils/db'
import { type GroupNotificationPayload } from '../../utils/message'
import { getGroupDisplayName } from '../../utils/user'
import { useConversationStore } from './conversationStore'
import { useGroupRequestStore } from './groupRequestStore'
/** clear() æ—¶é€’增;旧账号 in-flight çš„æˆå‘˜è¯·æ±‚返回后比对一致才写 store */
let storeEpoch = 0
/**
 * fetchGroupMemberList å¹¶å‘去重表:同 groupId åŒæ—¶è¿›çš„请求共用一个 Promise
 *
 * key å¿…须带 userId——账号切换时 A çš„请求不能被 B å¤ç”¨ï¼Œå¦åˆ™ IIFE å†…部的 saveGroupMemberList ä¼šæŠŠ A çš„æˆå‘˜æ•°æ®å†™è¿› B çš„ IDB æ¡¶
 */
const pendingMemberFetches = new Map<string, Promise<GroupMember[]>>()
const pendingMemberKey = (userId: number, groupId: number) => `${userId}:${groupId}`
/**
 * fetchGroupMember å•成员并发去重表:同 (groupId, memberUserId) åŒæ—¶è¿›çš„请求共用一个 Promise
 *
 * è·Ÿæ•´ç¾¤è¡¨åˆ†å¼€ï¼šå•成员 fetch è·Ÿæ•´ç¾¤ fetch è¯­ä¹‰ä¸åŒï¼ˆå•成员不回填 me çš„ silent),不能互相代替
 */
const pendingSingleMemberFetches = new Map<string, Promise<GroupMember | null>>()
const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: number) =>
  `${userId}:${groupId}:${memberUserId}`
/** æž„建群 IndexedDB è®°å½• */
function buildGroupDO(group: Group): GroupDO {
  const {
    activeCallExpired: _activeCallExpired,
    activeCallLoaded: _activeCallLoaded,
    infoLoaded: _infoLoaded,
    members: _members,
    membersLoaded: _membersLoaded,
    membersExpired: _membersExpired,
    ...record
  } = group
  return record
}
/** åˆ¤æ–­å½“前用户是否在 payload.memberUserIds é‡Œï¼ˆGROUP_CREATE / INVITE / KICK è‡ªåˆ¤ç”¨ï¼‰ */
function isSelfInPayloadMembers(payload: GroupNotificationPayload): boolean {
  const selfUserId = getCurrentUserId()
  return !!selfUserId && (payload.memberUserIds || []).includes(selfUserId)
}
/** åˆ·æ–°æˆ‘管理的群申请红点 */
function refreshUnhandledGroupRequests(): void {
  useGroupRequestStore()
    .fetchUnhandledGroupRequestList()
    .catch(() => undefined)
}
/**
 * IM ç¾¤ Store
 *
 * è´Ÿè´£ï¼š
 * - æ‹‰å– / ç¼“存当前登录用户加入的群列表
 * - æŒ‰ groupId æ‡’加载群成员(供 ConversationGroupSide / MentionPicker / MessageReadStatus æ¶ˆè´¹ï¼‰
 * - æˆå‘˜"已读 / æœªè¯»"等聚合查询由 MessageReadStatus å¦è¡Œç»„合
 */
export const useGroupStore = defineStore('imGroupStore', {
  state: () => ({
    groups: [] as Group[],
    loaded: false, // ä»… fetchGroupList æˆåŠŸåŽç½®ä½ï¼›loadGroupList(IDB)不置位,否则后台 SWR åˆ·æ–°ä¼šè¢«ç¼“存命中跳过
    groupMembersExpired: false // è¿›å…¥ IM / é‡è¿žåŽç½®ä½ï¼›IDB é‡Œçš„æˆå‘˜æ¡¶å»¶è¿ŸåŠ è½½åˆ°å†…å­˜æ—¶ï¼Œä¹Ÿè¦æŒ‰è¿‡æœŸå¤„ç†
  }),
  getters: {
    getGroup:
      (state) =>
      (id: number): Group | undefined => {
        return state.groups.find((g) => g.id === id)
      },
    /** ç¾¤æˆå‘˜ userId â†’ GroupMember ç´¢å¼•;调用方按 userId åæŸ¥æ˜µç§° / å¤´åƒç­‰å…ƒä¿¡æ¯ */
    getGroupMemberMap:
      (state) =>
      (id: number): Map<number, GroupMember> => {
        const group = state.groups.find((g) => g.id === id)
        return new Map((group?.members || []).map((m) => [m.userId, m]))
      }
  },
  actions: {
    // ==================== æœ¬åœ°ç¼“å­˜ ====================
    /** ä»Ž IndexedDB æ¢å¤ç¾¤åˆ—表 */
    async loadGroupList(): Promise<boolean> {
      try {
        const cached = await getDb().getAll<GroupDO>('groups')
        if (!cached || cached.length === 0) {
          return false
        }
        this.groups = cached
        return true
      } catch (error) {
        console.warn('[IM groupStore] æœ¬åœ°ç¾¤ç¼“存读取失败', error)
        return false
      }
    },
    /** ä¿å­˜ç¾¤åˆ—表 */
    saveGroupList(): void {
      void getDb()
        .transaction(['groups'], 'readwrite', async (tx) => {
          const db = getDb()
          await db.clearStore('groups', tx)
          for (const group of this.groups) {
            await db.put('groups', buildGroupDO(group), tx)
          }
        })
        .catch((error) => console.warn('[IM groupStore] æœ¬åœ°ç¾¤ç¼“存写入失败', error))
    },
    /** ä¿å­˜å•个群 */
    async saveGroupRecord(group: Group | undefined): Promise<void> {
      if (!group) {
        return
      }
      await getDb().put('groups', buildGroupDO(group))
    },
    /** ä¿å­˜å•个群 */
    saveGroup(group: Group | undefined): void {
      void this.saveGroupRecord(group).catch((error) =>
        console.warn('[IM groupStore] æœ¬åœ°ç¾¤å†™å…¥å¤±è´¥', error)
      )
    },
    /** ä»Ž IndexedDB æ¢å¤æŒ‡å®šç¾¤æˆå‘˜ */
    async loadGroupMemberList(groupId: number): Promise<GroupMember[] | null> {
      // in-memory å·²"完整"加载(fetchGroupMemberList è·‘过或上次冷启动从 IDB æ•´æ¡¶æ¢å¤è¿‡ï¼‰ï¼šç›´æŽ¥å¤ç”¨ï¼›
      // å•成员补齐(fetchGroupMember)写进的 partial members ä¸åœ¨æ­¤è¿”回缓存——其 membersLoaded=false
      const cachedGroup = this.getGroup(groupId)
      if (cachedGroup?.members && cachedGroup.membersLoaded) {
        return cachedGroup.members
      }
      try {
        const cached = await getDb().getAllByIndex<GroupMember>(
          'groupMembers',
          'groupId',
          groupId
        )
        if (!cached || cached.length === 0) {
          return null
        }
        // æŠŠ IDB æ‹¿åˆ°çš„æˆå‘˜è½åˆ°å¯¹åº” group
        const group = this.getGroup(groupId)
        if (group) {
          group.members = cached
          group.memberCount = cached.length
          group.membersLoaded = true
          group.membersExpired = this.groupMembersExpired
        } else {
          // group è¿˜æ²¡å°±ä½ï¼šä»… in-memory å ä½ï¼ˆname='' è¡¨ç¤ºæœªçŸ¥ï¼‰ï¼Œä¸è°ƒ upsertGroup â€”— é¿å…æŠŠå‡åçŒè¿› conversation.name + groups IDB æ¡¶ï¼›
          // åŽç»­ï¼Œç­‰ fetchGroupList æµ…合并时,被真名覆盖
          this.groups.push({
            id: groupId,
            name: '',
            members: cached,
            memberCount: cached.length,
            membersLoaded: true,
            membersExpired: this.groupMembersExpired
          })
        }
        return cached
      } catch (error) {
        console.warn('[IM groupStore] æœ¬åœ°ç¾¤æˆå‘˜ç¼“存读取失败', { groupId }, error)
        return null
      }
    },
    /** ä¿å­˜æŒ‡å®šç¾¤æˆå‘˜ */
    saveGroupMemberList(groupId: number): void {
      const members = this.getGroup(groupId)?.members
      if (!members) {
        return
      }
      void getDb()
        .transaction(['groupMembers'], 'readwrite', async (tx) => {
          const db = getDb()
          await db.deleteByIndex('groupMembers', 'groupId', groupId, tx)
          for (const member of members) {
            if (member.id) {
              await db.put('groupMembers', member, tx)
            }
          }
        })
        .catch((error) =>
          console.warn(`[IM groupStore] æœ¬åœ°ç¾¤æˆå‘˜ç¼“存写入失败 (groupId=${groupId})`, error)
        )
    },
    // ==================== è¿œç«¯æ‹‰å– ====================
    /** æ‹‰å–群列表;同步刷新对应群聊会话的展示名 / å¤´åƒ + è½ IDB */
    async fetchGroupList(force = false) {
      if (this.loaded && !force) {
        return
      }
      const requestEpoch = storeEpoch
      const requestUserId = getCurrentUserId()
      // æ‹‰å–当前登录用户加入的所有群(不带成员;成员按需再走 fetchGroupMemberList)
      const list = await apiGetMyGroupList()
      if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
        return
      }
      const fresh = (list || []).map((group) => convertGroup(group))
      // åˆå¹¶è€Œéžå…¨é‡æ›¿æ¢ï¼šæˆå‘˜ç¼“存只在成员列表接口维护,群个人设置以群列表接口为准
      const groupMap = new Map(this.groups.map((group) => [group.id, group]))
      this.groups = fresh.map((group) => {
        const existing = groupMap.get(group.id)
        if (!existing) {
          return { ...group, activeCallExpired: true, infoLoaded: true }
        }
        return {
          ...group,
          infoLoaded: true,
          activeCallExpired: existing.activeCallExpired,
          activeCallLoaded: existing.activeCallLoaded,
          members: existing.members,
          memberCount: existing.memberCount ?? group.memberCount,
          membersLoaded: existing.membersLoaded,
          membersExpired: existing.membersExpired
        }
      })
      this.loaded = true
      const conversationStore = useConversationStore()
      for (const group of this.groups) {
        conversationStore.updateConversation(ImConversationType.GROUP, group.id, {
          name: getGroupDisplayName(group),
          avatar: group.avatar,
          silent: group.silent
        })
      }
      this.saveGroupList()
      this.preloadMembersForEmptyAvatarGroups()
    },
    /** å¤±æ•ˆå…¨éƒ¨ç¾¤è¯¦æƒ…缓存 */
    markAllGroupInfoExpired() {
      for (const group of this.groups) {
        group.infoLoaded = false
      }
    },
    /** é¢„加载空群头像的成员列表,供 GroupAvatar å¼‚步合成群头像 */
    preloadMembersForEmptyAvatarGroups() {
      for (const group of this.groups) {
        if (
          group.avatar ||
          group.joinStatus === CommonStatusEnum.DISABLE ||
          (group.membersLoaded && !group.membersExpired && group.members?.length)
        ) {
          continue
        }
        const force = !!group.membersLoaded && !group.membersExpired && !group.members?.length
        this.fetchGroupMemberList(group.id, force).catch((error) => {
          console.warn('[IM groupStore] é¢„加载群头像成员失败', { groupId: group.id }, error)
        })
      }
    },
    /** å¤±æ•ˆå…¨éƒ¨ç¾¤æˆå‘˜ç¼“å­˜ */
    markAllGroupMembersExpired() {
      this.groupMembersExpired = true
      for (const group of this.groups) {
        if (group.membersLoaded) {
          group.membersExpired = true
        }
      }
    },
    /** å¤±æ•ˆå…¨éƒ¨ç¾¤é€šè¯æŽ¢æµ‹ç¼“å­˜ */
    markAllGroupActiveCallsExpired() {
      for (const group of this.groups) {
        group.activeCallExpired = true
      }
    },
    /** æ ‡è®°ç¾¤é€šè¯æŽ¢æµ‹å·²åŠ è½½ */
    markGroupActiveCallLoaded(groupId: number) {
      const group = this.getGroup(groupId)
      if (!group) {
        return
      }
      group.activeCallLoaded = true
      group.activeCallExpired = false
    },
    /** åˆ¤æ–­ç¾¤é€šè¯æ˜¯å¦éœ€è¦é‡æ–°æŽ¢æµ‹ */
    isGroupActiveCallExpired(groupId: number): boolean {
      const group = this.getGroup(groupId)
      return !group?.activeCallLoaded || !!group.activeCallExpired
    },
    /** å¤±æ•ˆæŒ‡å®šç¾¤æˆå‘˜ç¼“å­˜ */
    markGroupMembersExpired(groupId: number) {
      const group = this.getGroup(groupId)
      if (group?.membersLoaded) {
        group.membersExpired = true
      }
    },
    /** å•群刷新:用 /im/group/get æ‹‰ä¸€ä»½æœ€æ–°å…ƒæ•°æ®å† upsert,常用于 GROUP_UPDATE æŽ¨é€åŽæˆ–手动 reload */
    async fetchGroupInfo(groupId: number, force = false) {
      const cached = this.getGroup(groupId)
      if (cached?.infoLoaded && !force) {
        return
      }
      try {
        const data = await apiGetGroup(groupId)
        if (!data) {
          return
        }
        this.upsertGroup({ ...convertGroup(data), infoLoaded: true })
      } catch (error) {
        console.warn('[IM groupStore] fetchGroupInfo å¤±è´¥', error)
      }
    },
    /** æŒ‰ç¾¤æ‹‰å–成员(in-memory ç¼“å­˜ + å¹¶å‘去重,force=true å¼ºåˆ·ï¼‰+ è½ IDB */
    fetchGroupMemberList(groupId: number, force = false): Promise<GroupMember[]> {
      // in-memory "完整"加载过才命中——单成员补齐写入的 partial members ä¸åœ¨æ­¤è¿”回(membersLoaded=false)
      const cached = this.getGroup(groupId)
      if (cached && cached.members && cached.membersLoaded && !cached.membersExpired && !force) {
        return Promise.resolve(cached.members)
      }
      // æœªç™»å½•:不发起请求也不登记 in-flight,避免污染单飞表
      const requestUserId = getCurrentUserId()
      if (!requestUserId) {
        return Promise.resolve([])
      }
      const requestEpoch = storeEpoch
      // åŒ (userId, groupId) å·²ç»æœ‰æ­£åœ¨é£žçš„请求:直接复用,避免重复打接口
      const key = pendingMemberKey(requestUserId, groupId)
      const inflight = pendingMemberFetches.get(key)
      if (inflight) {
        return inflight
      }
      const promise = (async () => {
        // æ‹‰æŽ¥å£ + å• pass è½¬æ¢ï¼šåŒæ—¶æ•获 me çš„原始 VO,给下面回填 user-per-group å­—段(silent / groupRemark)用
        const list = await apiGetGroupMemberList(groupId)
        if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
          return []
        }
        let meRaw: ImGroupMemberApi.GroupMemberRespVO | undefined
        const members = (list || []).map((member) => {
          if (member.userId === requestUserId) {
            meRaw = member
          }
          return convertGroupMember(member, groupId)
        })
        const silent = !!meRaw?.silent
        const groupRemark = meRaw?.groupRemark || ''
        // å¿…é¡» await ä¹‹åŽé‡æ–° getGroup,避免 fetchGroupList å·²å¹¶å‘写入真实 group çš„ race
        const group = this.getGroup(groupId)
        const isPlaceholder = !group
        let groupFieldsChanged = false
        if (group) {
          group.members = members
          group.memberCount = members.length
          group.membersLoaded = true
          group.membersExpired = false
          // silent / groupRemark ä»»ä¸€å˜åŒ–才同步到 conversation å’Œ IDB;groupRemark å˜åŒ–要顺带刷会话名
          if (group.silent !== silent || group.groupRemark !== groupRemark) {
            group.silent = silent
            group.groupRemark = groupRemark
            groupFieldsChanged = true
            const conversationStore = useConversationStore()
            conversationStore.updateConversation(ImConversationType.GROUP, groupId, {
              name: getGroupDisplayName(group),
              silent
            })
          }
        } else {
          // group è¿˜æ²¡å°±ä½ï¼šä»… in-memory push å ä½ï¼ˆname='' è¡¨ç¤ºæœªçŸ¥ï¼‰ï¼Œä¸è°ƒ upsertGroup——避免把假名灌进 conversation.name + groups IDB æ¡¶ï¼›
          // åŽç»­ï¼Œç­‰ fetchGroupList æµ…合并时,被真名覆盖
          this.groups.push({
            id: groupId,
            name: '',
            members,
            memberCount: members.length,
            silent,
            groupRemark,
            membersLoaded: true,
            membersExpired: false
          })
        }
        // groups æ¡¶ä»…在 user-per-group å­—段实际变化时写——避免一次批量进群引发多次整桶重写
        this.saveGroupMemberList(groupId)
        if (!isPlaceholder && groupFieldsChanged) {
          this.saveGroup(group)
        }
        return members
        // æ— è®ºæˆåŠŸ / å¤±è´¥éƒ½è¦ä»Žå•飞表清掉,否则后续同 group è¯·æ±‚永远拿到这个 stale Promise
      })().finally(() => pendingMemberFetches.delete(key))
      // æŠŠ Promise ç™»è®°è¿›å•飞表,让此后短时间内的同 (userId, groupId) è¯·æ±‚复用
      pendingMemberFetches.set(key, promise)
      return promise
    },
    /**
     * æŒ‰ (groupId, memberUserId) å•成员补齐——deriveLastSenderDisplayName å…œåº•场景用
     *
     * è·Ÿ fetchGroupMemberList åŒºåˆ«ï¼šåªæ‹‰è¿™ä¸€ä¸ªæˆå‘˜ï¼Œä¸åЍ me çš„ silent / groupRemark(不是 me çš„话拿不到);
     * å‘½ä¸­æ—¶æŠŠæˆå‘˜ upsert è¿› group.members æ•°ç»„并落 IDB,让后续渲染能用 displayUserName
     */
    fetchGroupMember(groupId: number, memberUserId: number): Promise<GroupMember | null> {
      // in-memory å‘½ä¸­ç›´æŽ¥è¿”回,不打接口
      const cached = this.getGroup(groupId)?.members?.find((m) => m.userId === memberUserId)
      if (cached) {
        return Promise.resolve(cached)
      }
      // æœªç™»å½•:不发起请求也不登记 in-flight,避免污染单飞表
      const requestUserId = getCurrentUserId()
      if (!requestUserId) {
        return Promise.resolve(null)
      }
      const requestEpoch = storeEpoch
      // åŒ (userId, groupId, memberUserId) å·²ç»æœ‰æ­£åœ¨é£žçš„请求:直接复用
      const key = pendingSingleMemberKey(requestUserId, groupId, memberUserId)
      const inflight = pendingSingleMemberFetches.get(key)
      if (inflight) {
        return inflight
      }
      const promise = (async () => {
        const data = await apiGetGroupMember(groupId, memberUserId)
        if (!data) {
          return null
        }
        if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
          return null
        }
        const member = convertGroupMember(data, groupId)
        // æŠŠè¿™ä¸€æ¡ upsert è¿› group.members ä»…ä¾› in-memory æ¸²æŸ“兜底;group è¿˜æ²¡å°±ä½åˆ™ç”¨ placeholder
        // æ³¨æ„ï¼šä¸å†™ IDB——成员桶语义是"全量",存"1 äººæ¡¶"会污染下次冷启动的 loadGroupMemberList
        const group = this.getGroup(groupId)
        if (group) {
          const memberList = group.members ?? []
          const index = memberList.findIndex((m) => m.userId === memberUserId)
          if (index === -1) {
            memberList.push(member)
          } else {
            memberList[index] = member
          }
          group.members = memberList
        } else {
          // memberCount ä¸è®¾ï¼šåŽç»­ fetchGroupList åˆå¹¶ `existing.memberCount ?? fresh.memberCount` æ—¶ï¼Œ
          // å ä½å€¼ä¼šé¡¶æ›¿çœŸå®žå€¼ï¼ˆfresh ä¸å¸¦ memberCount),等 fetchGroupMemberList è·‘过才能拿到真实数
          this.groups.push({
            id: groupId,
            name: '',
            members: [member]
          })
        }
        return member
      })().finally(() => pendingSingleMemberFetches.delete(key))
      pendingSingleMemberFetches.set(key, promise)
      return promise
    },
    /** æŒ‰ id æ’入或合并群(命中则浅合并保留旧字段,未命中则追加),同步把展示名 / å¤´åƒ / å…æ‰“扰推到对应会话 */
    upsertGroup(group: Group) {
      void this.upsertGroupAndSave(group).catch((error) =>
        console.warn('[IM groupStore] æœ¬åœ°ç¾¤å†™å…¥å¤±è´¥', error)
      )
    },
    /** æŒ‰ id æ’入或合并群 */
    async upsertGroupAndSave(group: Group): Promise<void> {
      const index = this.groups.findIndex((g) => g.id === group.id)
      if (index === -1) {
        this.groups.push(group)
      } else {
        this.groups[index] = { ...this.groups[index], ...group }
      }
      // åŒæ­¥æŽ¨åˆ° conversation:群名 / å¤´åƒ / å…æ‰“扰是会话列表展示用的,必须紧随 group å˜æ›´
      const merged = this.getGroup(group.id) ?? group
      const conversationStore = useConversationStore()
      conversationStore.updateConversation(ImConversationType.GROUP, group.id, {
        name: getGroupDisplayName(merged),
        avatar: merged.avatar,
        silent: merged.silent
      })
      // æŒä¹…化到 IDB(fire-and-forget)
      await this.saveGroupRecord(merged)
    },
    /** æœ¬åœ°ç§»é™¤ç¾¤ç¼“存和群会话;群解散(GROUP_DEL)、退群、被踢都复用 */
    removeGroup(id: number) {
      // æœ¬åœ°ç¡¬åˆ ï¼ˆåŒºåˆ«äºŽå¥½å‹åˆ é™¤çš„软删保留记录);级联清群聊会话避免列表里留死群
      this.groups = this.groups.filter((g) => g.id !== id)
      const conversationStore = useConversationStore()
      conversationStore.removeGroupConversation(id)
      void getDb()
        .transaction(['groups', 'groupMembers'], 'readwrite', async (tx) => {
          const db = getDb()
          await db.delete('groups', id, tx)
          await db.deleteByIndex('groupMembers', 'groupId', id, tx)
        })
        .catch((error) => console.warn(`[IM groupStore] ç¾¤ç¼“存删除失败 (groupId=${id})`, error))
    },
    /** åˆ‡æ¢å…æ‰“扰:推后端 + è½æœ¬åœ° + åŒæ­¥ä¼šè¯åˆ—表的 silent,避免 silent å›¾æ ‡ / æ€»æœªè¯» / æç¤ºéŸ³åˆ¤æ–­ä¸Žè®¾ç½®æ¼‚移;和 friendStore.setFriendSilent å¯¹é½ */
    async setGroupSilent(id: number, silent: boolean) {
      await apiUpdateGroupMember({ groupId: id, silent })
      const group = this.getGroup(id)
      if (!group) {
        return
      }
      group.silent = silent
      const conversationStore = useConversationStore()
      conversationStore.updateConversation(ImConversationType.GROUP, id, { silent })
      this.saveGroup(group)
    },
    /** æ‰¹é‡æ›´æ–°ç¾¤æˆå‘˜è§’色;本地不命中则忽略,等 fetchGroupMemberList å…œåº• */
    updateGroupMemberRoleList(groupId: number, userIds: number[], role: number) {
      const group = this.getGroup(groupId)
      if (!group?.members?.length) {
        return
      }
      // å‘½ä¸­ç›®æ ‡ä¸”角色已变化才标记 changed,避免无变化时整数组重建触发响应式
      const idSet = new Set(userIds)
      let changed = false
      const newMembers = group.members.map((member) => {
        if (!idSet.has(member.userId) || member.role === role) {
          return member
        }
        changed = true
        return { ...member, role }
      })
      // æœ‰å˜åŒ–才整组替换,让响应式只在真有更新时通知下游
      if (changed) {
        group.members = newMembers
        this.saveGroupMemberList(groupId)
      }
    },
    /** ç¾¤ä¸»è½¬è®©ï¼šç¾¤è¡¨ ownerUserId æ”¹ä¸ºæ–°å€¼ï¼›æ—§ç¾¤ä¸» role â†’ NORMAL;新群主 role â†’ OWNER */
    transferGroupOwner(groupId: number, oldOwnerId: number, newOwnerId: number) {
      const group = this.getGroup(groupId)
      if (!group) {
        return
      }
      if (group.ownerUserId !== newOwnerId) {
        group.ownerUserId = newOwnerId
      }
      this.updateGroupMemberRoleList(groupId, [oldOwnerId], ImGroupMemberRole.NORMAL)
      this.updateGroupMemberRoleList(groupId, [newOwnerId], ImGroupMemberRole.OWNER)
      this.saveGroup(group)
    },
    /** æœ¬åœ°å‰”除群成员(GROUP_MEMBER_QUIT / KICK äº‹ä»¶ï¼‰ï¼›ä¸å‘½ä¸­åˆ™ç­‰ fetchGroupMemberList å…œåº• */
    removeLocalGroupMemberList(groupId: number, userIds: number[]) {
      const group = this.getGroup(groupId)
      if (!group?.members?.length || userIds.length === 0) {
        return
      }
      const idSet = new Set(userIds)
      const next = group.members.filter((member) => !idSet.has(member.userId))
      if (next.length === group.members.length) {
        return
      }
      group.members = next
      group.memberCount = next.length
      this.saveGroupMemberList(groupId)
    },
    /** æœ¬åœ°æ›´æ–°ç¾¤æˆå‘˜çš„ status(自己退群 / è¢«è¸¢çš„æœ¬åœ°é¢„置;让 isMember ç«‹å³æ”¶æ•›åˆ° stranger,不依赖 removeGroup çš„æ•´ç¾¤ç§»é™¤ï¼‰ */
    updateGroupMemberStatus(groupId: number, userId: number, status: number) {
      const group = this.getGroup(groupId)
      const member = group?.members?.find((m) => m.userId === userId)
      if (!member || member.status === status) {
        return
      }
      member.status = status
      this.saveGroupMemberList(groupId)
    },
    /** æœ¬åœ°æ›´æ–°ç¾¤æˆå‘˜çš„ displayUserName(GROUP_MEMBER_NICKNAME_UPDATE äº‹ä»¶ï¼‰ï¼›ä¸å‘½ä¸­åˆ™ç­‰ fetchGroupMemberList å…œåº• */
    updateGroupMemberDisplayUserName(groupId: number, userId: number, displayUserName: string) {
      const group = this.getGroup(groupId)
      const member = group?.members?.find((m) => m.userId === userId)
      if (!member || member.displayUserName === displayUserName) {
        return
      }
      member.displayUserName = displayUserName
      this.saveGroupMemberList(groupId)
    },
    /** å±€éƒ¨æ›´æ–°ç¾¤å­—段(name / notice / avatar ç­‰ï¼‰ï¼›æœªå‘½ä¸­æœ¬åœ°ç¼“存时静默忽略,等 fetchGroupList å…œåº•;新值跟旧值都相同时跳过响应式 + IDB å†™ */
    updateGroupFields(groupId: number, fields: Partial<Group>) {
      const group = this.getGroup(groupId)
      if (!group) {
        return
      }
      const changed = (Object.keys(fields) as (keyof Group)[]).some((k) => group[k] !== fields[k])
      if (!changed) {
        return
      }
      Object.assign(group, fields)
      const conversationStore = useConversationStore()
      conversationStore.updateConversation(ImConversationType.GROUP, groupId, {
        name: getGroupDisplayName(group),
        avatar: group.avatar,
        silent: group.silent
      })
      this.saveGroup(group)
    },
    /**
     * æŽ¥æ”¶ GROUP_* ç¾¤å¹¿æ’­äº‹ä»¶ï¼ŒæŒ‰ type åˆ†å‘到对应私有 action
     *
     * WebSocket å®žæ—¶æ”¶èµ° messageStore.insertMessage æ—è·¯è°ƒç”¨
     * store é‡Œæ²¡ç¼“存的群静默忽略,等 fetchGroupList å…œåº•
     */
    applyGroupNotification(groupId: number, type: number, content?: string) {
      if (!groupId) {
        return
      }
      let payload: GroupNotificationPayload
      try {
        payload = content ? JSON.parse(content) : {}
      } catch (error) {
        console.warn(
          '[IM groupStore] applyGroupNotification è§£æž content å¤±è´¥',
          { groupId, type, contentLength: content?.length ?? 0 },
          error
        )
        return
      }
      switch (type) {
        case ImContentType.GROUP_ADMIN_ADD: {
          this.updateGroupMemberRoleList(
            groupId,
            payload.memberUserIds || [],
            ImGroupMemberRole.ADMIN
          )
          this.markGroupMembersExpired(groupId)
          // è‡ªå·±è¢«åŠ ä¸ºç®¡ç†å‘˜ï¼ŒåŽŸæœ¬çœ‹ä¸åˆ°çš„ç¾¤ä¸‹æœªå¤„ç†ç”³è¯·çŽ°åœ¨å˜å¯è§ï¼Œé‡æ–°æ‹‰ä¸€æ¬¡ unhandledList
          if (isSelfInPayloadMembers(payload)) {
            refreshUnhandledGroupRequests()
          }
          break
        }
        case ImContentType.GROUP_ADMIN_REMOVE: {
          this.updateGroupMemberRoleList(
            groupId,
            payload.memberUserIds || [],
            ImGroupMemberRole.NORMAL
          )
          this.markGroupMembersExpired(groupId)
          if (isSelfInPayloadMembers(payload)) {
            refreshUnhandledGroupRequests()
          }
          break
        }
        case ImContentType.GROUP_BANNED: {
          this.updateGroupFields(groupId, { banned: !!payload.banned })
          break
        }
        case ImContentType.GROUP_CANCEL_MUTED: {
          this.updateGroupFields(groupId, { mutedAll: false })
          break
        }
        case ImContentType.GROUP_CREATE: {
          this.applyGroupCreateNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_DISSOLVE: {
          this.removeGroup(groupId)
          break
        }
        case ImContentType.GROUP_INFO_UPDATE: {
          this.applyGroupInfoUpdateNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MEMBER_CANCEL_MUTED: {
          this.applyGroupMemberCancelMutedNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MEMBER_ENTER: {
          this.applyGroupMemberEnterNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MEMBER_INVITE: {
          this.applyGroupMemberInviteNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MEMBER_KICK: {
          this.applyGroupMemberKickNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MEMBER_MUTED: {
          this.applyGroupMemberMutedNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: {
          this.applyGroupMemberNicknameUpdateNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MEMBER_QUIT: {
          this.applyGroupMemberQuitNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MESSAGE_PIN: {
          this.applyGroupMessagePinNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MESSAGE_UNPIN: {
          this.applyGroupMessageUnpinNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MUTED: {
          this.updateGroupFields(groupId, { mutedAll: true })
          break
        }
        case ImContentType.GROUP_NAME_UPDATE: {
          this.applyGroupNameUpdateNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_NOTICE_UPDATE: {
          this.applyGroupNoticeUpdateNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_OWNER_TRANSFER: {
          this.applyGroupOwnerTransferNotification(groupId, payload)
          break
        }
      }
    },
    /** åˆ›å»ºç¾¤å¹¿æ’­ï¼šç¾¤æœªå°±ä½æ—¶æ‹‰ç¾¤è¯¦æƒ… */
    async applyGroupCreateNotification(groupId: number, payload: GroupNotificationPayload) {
      if (!isSelfInPayloadMembers(payload)) {
        return
      }
      const selfUserId = getCurrentUserId()
      const selfIsOperator = !!selfUserId && payload.operatorUserId === selfUserId
      if (selfIsOperator && this.getGroup(groupId)) {
        return
      }
      await this.fetchGroupInfo(groupId, true)
    },
    /** ç¾¤åå˜æ›´ï¼šæŒ‰ newName å±€éƒ¨æ›´æ–°æœ¬åœ°ç¾¤å */
    applyGroupNameUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
      if (payload.newName) {
        this.updateGroupFields(groupId, { name: payload.newName })
      }
    },
    /** ç¾¤å…¬å‘Šå˜æ›´ï¼šæŒ‰ newNotice å±€éƒ¨æ›´æ–°ï¼ˆå…è®¸ç©ºä¸²ä½œä¸ºã€Œæ¸…空公告」) */
    applyGroupNoticeUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
      this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' })
    },
    /** ç¾¤ä¿¡æ¯å˜æ›´ï¼šåŒæ­¥å¤´åƒã€è¿›ç¾¤å®¡æ‰¹ */
    applyGroupInfoUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
      const fields: Partial<Group> = {}
      if (payload.newAvatar) {
        fields.avatar = payload.newAvatar
      }
      if (payload.newJoinApproval != null) {
        fields.joinApproval = payload.newJoinApproval
      }
      if (Object.keys(fields).length > 0) {
        this.updateGroupFields(groupId, fields)
      }
    },
    /** æˆå‘˜åŠ å…¥ï¼šè¢«é‚€è¯·è€…æœ¬ç«¯ group æœªå°±ä½å…ˆ fetchGroupInfo åˆæ¬¡æ‹‰å–;所有人都刷成员列表(新成员 nickname / avatar ä¸åœ¨ payload) */
    async applyGroupMemberInviteNotification(groupId: number, payload: GroupNotificationPayload) {
      // è‡ªå·±åˆšè¢«æ‹‰è¿›æ¥ï¼šå¿…é¡» await fetchGroupInfo è®©ç¾¤å…¥ state.groups,否则 fetchGroupMemberList çš„ guard ä¼šå…œç©º
      if (isSelfInPayloadMembers(payload) && !this.getGroup(groupId)) {
        await this.fetchGroupInfo(groupId, true)
      }
      this.markGroupMembersExpired(groupId)
      this.fetchGroupMemberList(groupId, true).catch(() => undefined)
    },
    /** è‡ªç”±è¿›ç¾¤ï¼šè¿›ç¾¤è€…本端 group æœªå°±ä½å…ˆ fetchGroupInfo åˆæ¬¡æ‹‰å–;所有人都刷成员列表 */
    async applyGroupMemberEnterNotification(groupId: number, payload: GroupNotificationPayload) {
      const selfUserId = getCurrentUserId()
      // è‡ªå·±è‡ªç”±è¿›ç¾¤ï¼šå¿…é¡» await fetchGroupInfo è®©ç¾¤å…¥ state.groups,否则 fetchGroupMemberList çš„ guard ä¼šå…œç©º
      if (selfUserId && payload.entrantUserId === selfUserId && !this.getGroup(groupId)) {
        await this.fetchGroupInfo(groupId, true)
      }
      this.markGroupMembersExpired(groupId)
      this.fetchGroupMemberList(groupId, true).catch(() => undefined)
    },
    /** æˆå‘˜é€€ç¾¤ï¼šé€€ç¾¤è€…本人先把 self.status ç½® DISABLE å† removeGroup(保留状态语义 + ç»´æŒ groups åˆ—表干净);其他成员从本地列表移除 quitter */
    applyGroupMemberQuitNotification(groupId: number, payload: GroupNotificationPayload) {
      const selfUserId = getCurrentUserId()
      if (selfUserId && payload.operatorUserId === selfUserId) {
        this.updateGroupMemberStatus(groupId, selfUserId, CommonStatusEnum.DISABLE)
        this.removeGroup(groupId)
      } else if (payload.operatorUserId) {
        this.removeLocalGroupMemberList(groupId, [payload.operatorUserId])
        this.markGroupMembersExpired(groupId)
      }
    },
    /** æˆå‘˜è¢«ç§»å‡ºï¼šè¢«è¸¢è€…本人先把 self.status ç½® DISABLE å† removeGroup;其他成员从本地列表移除被踢者 */
    applyGroupMemberKickNotification(groupId: number, payload: GroupNotificationPayload) {
      const memberIds = payload.memberUserIds || []
      const selfUserId = getCurrentUserId()
      if (isSelfInPayloadMembers(payload)) {
        if (selfUserId) {
          this.updateGroupMemberStatus(groupId, selfUserId, CommonStatusEnum.DISABLE)
        }
        this.removeGroup(groupId)
      } else if (memberIds.length > 0) {
        this.removeLocalGroupMemberList(groupId, memberIds)
        this.markGroupMembersExpired(groupId)
      }
    },
    /** æˆå‘˜æ˜µç§°å˜æ›´ï¼šæŒ‰ operatorUserId å±€éƒ¨æ›´æ–°å¯¹åº” member.displayUserName */
    applyGroupMemberNicknameUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
      if (payload.operatorUserId) {
        this.updateGroupMemberDisplayUserName(
          groupId,
          payload.operatorUserId,
          payload.displayUserName ?? ''
        )
        this.markGroupMembersExpired(groupId)
      }
    },
    /** ç¾¤ä¸»è½¬è®©ï¼šæ—§ç¾¤ä¸» â†’ NORMAL,新群主 â†’ OWNER;新群主自己侧重新拉申请列表 */
    applyGroupOwnerTransferNotification(groupId: number, payload: GroupNotificationPayload) {
      if (payload.operatorUserId && payload.newOwnerUserId) {
        this.transferGroupOwner(groupId, payload.operatorUserId, payload.newOwnerUserId)
        this.markGroupMembersExpired(groupId)
      }
      // è‡ªå·±æŽ¥ç®¡ç¾¤ä¸»ï¼šåŽŸæœ¬çœ‹ä¸åˆ°çš„ç¾¤ä¸‹æœªå¤„ç†ç”³è¯·çŽ°åœ¨å˜å¯è§ï¼Œé‡æ–°æ‹‰ä¸€æ¬¡ unhandledList
      const selfUserId = getCurrentUserId()
      if (selfUserId && payload.newOwnerUserId === selfUserId) {
        refreshUnhandledGroupRequests()
      } else if (selfUserId && payload.operatorUserId === selfUserId) {
        refreshUnhandledGroupRequests()
      }
    },
    /** ç¾¤æ¶ˆæ¯ç½®é¡¶ï¼šä»Ž payload å–消息展示数据加入置顶列表 */
    applyGroupMessagePinNotification(groupId: number, payload: GroupNotificationPayload) {
      const message = payload.message
      if (!message) {
        return
      }
      const group = this.getGroup(groupId)
      if (!group) {
        return
      }
      // å¹‚等:已存在同 messageId ä¸é‡å¤ push
      const existing = group.pinnedMessages || []
      if (existing.some((msg) => msg.id === message.id)) {
        return
      }
      group.pinnedMessages = [
        ...existing,
        {
          id: message.id,
          clientMessageId: '',
          senderId: message.senderId,
          type: message.type,
          content: message.content,
          status: ImMessageStatus.NORMAL,
          sendTime: new Date(message.sendTime).getTime(),
          targetId: message.groupId || groupId,
          selfSend: message.senderId === getCurrentUserId(),
          atUserIds: message.atUserIds ? [...message.atUserIds] : [],
          receiverUserIds: message.receiverUserIds ? [...message.receiverUserIds] : []
        }
      ]
      this.saveGroup(group)
    },
    /** ç¾¤æ¶ˆæ¯å–消置顶:按 messageId ä»Žæœ¬åœ°ç½®é¡¶åˆ—表中移除 */
    applyGroupMessageUnpinNotification(groupId: number, payload: GroupNotificationPayload) {
      if (!payload.messageId) {
        return
      }
      const group = this.getGroup(groupId)
      if (!group?.pinnedMessages?.length) {
        return
      }
      const newPinnedMessages = group.pinnedMessages.filter((m) => m.id !== payload.messageId)
      if (newPinnedMessages.length === group.pinnedMessages.length) {
        return
      }
      group.pinnedMessages = newPinnedMessages
      this.saveGroup(group)
    },
    /** å•成员禁言:更新目标成员的 muteEndTime */
    applyGroupMemberMutedNotification(groupId: number, payload: GroupNotificationPayload) {
      const group = this.getGroup(groupId)
      const member = group?.members?.find((m) => m.userId === payload.mutedUserId)
      if (member && payload.muteEndTime) {
        member.muteEndTime = payload.muteEndTime
        this.saveGroupMemberList(groupId)
        this.markGroupMembersExpired(groupId)
      }
    },
    /** å•成员取消禁言:清空目标成员的 muteEndTime */
    applyGroupMemberCancelMutedNotification(groupId: number, payload: GroupNotificationPayload) {
      const group = this.getGroup(groupId)
      const member = group?.members?.find((m) => m.userId === payload.mutedUserId)
      if (member) {
        member.muteEndTime = undefined
        this.saveGroupMemberList(groupId)
        this.markGroupMembersExpired(groupId)
      }
    },
    /** åˆ‡è´¦å·æ—¶ä»…清 in-memory,IDB æŒ‰ userId åˆ†æ¡¶å¤©ç„¶éš”离,回切秒开 */
    clear() {
      this.groups = []
      this.loaded = false
      this.groupMembersExpired = false
      // è´¦å·åˆ‡æ¢ï¼šé€’增 epoch åºŸå¼ƒæ—§è´¦å· in-flight çš„æˆå‘˜è¯·æ±‚
      storeEpoch++
      // å•飞表跟 in-memory state ä¸€èµ·é‡ç½®ï¼›æ—§è´¦å· in-flight çš„请求 finally ä¹Ÿä¼šè‡ªå·± delete key,提前清空只是更干脆
      pendingMemberFetches.clear()
      pendingSingleMemberFetches.clear()
    }
  }
})
function convertGroup(group: ImGroupApi.GroupRespVO): Group {
  return {
    id: group.id,
    name: group.name,
    avatar: group.avatar,
    notice: group.notice,
    ownerUserId: group.ownerUserId,
    pinnedMessages: group.pinnedMessages?.map((message) => convertGroupMessageVO(message)),
    mutedAll: group.mutedAll,
    banned: group.banned,
    joinApproval: group.joinApproval,
    joinStatus: group.joinStatus,
    groupRemark: group.groupRemark,
    silent: group.silent
  }
}
/** åŽç«¯ ImGroupMessageApi.GroupMessageRespVO -> å‰ç«¯ Message:补 targetId / selfSend / sendTime ç­‰æ´¾ç”Ÿå­—段 */
function convertGroupMessageVO(
  message: NonNullable<ImGroupApi.GroupRespVO['pinnedMessages']>[number]
): Message {
  const currentUserId = getCurrentUserId()
  return {
    id: message.id,
    clientMessageId: message.clientMessageId || '',
    type: message.type,
    content: message.content,
    status: message.status,
    sendTime: new Date(message.sendTime).getTime(),
    senderId: message.senderId,
    targetId: message.groupId,
    selfSend: !!currentUserId && message.senderId === currentUserId,
    atUserIds: message.atUserIds || [],
    receiverUserIds: message.receiverUserIds || [],
    receiptStatus: message.receiptStatus,
    readCount: message.readCount
  }
}
function convertGroupMember(member: ImGroupMemberApi.GroupMemberRespVO, groupId: number): GroupMember {
  return {
    id: member.id,
    userId: member.userId,
    groupId,
    nickname: member.nickname || String(member.userId),
    avatar: member.avatar,
    displayUserName: member.displayUserName,
    status: member.status,
    role: member.role,
    muteEndTime: member.muteEndTime
  }
}
export const useGroupStoreWithOut = () => useGroupStore()
// dev: è®© Pinia çš„ actions æ”¹åŠ¨æ”¯æŒ HMR,免去每次改 store éƒ½è¦ç¡¬åˆ·
if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useGroupStore, import.meta.hot))
}