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))
|
}
|