admin-vue3/src/views/im/home/store/groupStore.ts

781 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { defineStore, acceptHMRUpdate } from 'pinia'
import { store } from '@/store'
import {
getMyGroupList as apiGetMyGroupList,
getGroup as apiGetGroup,
type ImGroupRespVO
} from '@/api/im/group'
import {
getGroupMember as apiGetGroupMember,
getGroupMemberList as apiGetGroupMemberList,
updateGroupMember as apiUpdateGroupMember,
type ImGroupMemberRespVO
} from '@/api/im/group/member'
import { useConversationStore } from './conversationStore'
import { ImConversationType, ImGroupMemberRole, ImMessageType } from '../../utils/constants'
import {
getCurrentUserId,
imStorage,
removeQuietly,
setQuietly,
StorageKeys
} from '../../utils/storage'
import { getGroupDisplayName, type GroupNotificationPayload } from '../../utils/user'
import type { Group, GroupMember, Message } from '../types'
/**
* fetchGroupMembers 并发去重表:同 groupId 同时进的请求共用一个 Promise
*
* key 必须带 userId——账号切换时 A 的请求不能被 B 复用,否则 IIFE 内部的 saveGroupMembers 会把 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}`
/** 判断当前用户是否在 payload.memberUserIds 里GROUP_CREATE / INVITE / KICK 自判用) */
function isSelfInPayloadMembers(payload: GroupNotificationPayload): boolean {
const selfUserId = getCurrentUserId()
return !!selfUserId && (payload.memberUserIds || []).includes(selfUserId)
}
/**
* IM 群 Store
*
* 负责:
* - 拉取 / 缓存当前登录用户加入的群列表
* - 按 groupId 懒加载群成员(供 ConversationGroupSide / MentionPicker / MessageReadStatus 消费)
* - 成员"已读 / 未读"等聚合查询由 MessageReadStatus 另行组合
*/
export const useGroupStore = defineStore('imGroupStore', {
state: () => ({
groups: [] as Group[],
// 仅 fetchGroups 成功后置位loadGroupsIDB不置位否则后台 SWR 刷新会被缓存命中跳过
loaded: false
}),
getters: {
getGroup:
(state) =>
(id: number): Group | undefined => {
return state.groups.find((g) => g.id === id)
}
},
actions: {
// ==================== 本地缓存 ====================
/** 从 IDB 恢复群列表(不带 members返回是否命中缓存 */
async loadGroups(): Promise<boolean> {
const userId = getCurrentUserId()
if (!userId) {
return false
}
try {
const cached = await imStorage.getItem<Group[]>(StorageKeys.groups(userId))
if (!cached || cached.length === 0) {
return false
}
this.groups = cached
return true
} catch (e) {
console.warn('[IM groupStore] 本地群缓存读取失败', e)
return false
}
},
/** 整桶持久化群列表;剥离 members 字段,成员另走 groupMembers:${groupId} 分桶 */
saveGroups(): void {
const userId = getCurrentUserId()
if (!userId) {
return
}
const groupsWithoutMembers = this.groups.map(({ members, ...rest }) => rest)
setQuietly(
StorageKeys.groups(userId),
groupsWithoutMembers,
'[IM groupStore] 本地群缓存写入失败'
)
},
/** 从 IDB 恢复指定群成员;命中返回成员数组,未命中返回 null */
async loadGroupMembers(groupId: number): Promise<GroupMember[] | null> {
const userId = getCurrentUserId()
if (!userId) {
return null
}
// in-memory 已"完整"加载fetchGroupMembers 跑过或上次冷启动从 IDB 整桶恢复过):直接复用;
// 单成员补齐fetchGroupMember写进的 partial members 不在此返回缓存——其 membersLoaded=false
const cachedGroup = this.getGroup(groupId)
if (cachedGroup?.members && cachedGroup.membersLoaded) {
return cachedGroup.members
}
try {
const cached = await imStorage.getItem<GroupMember[]>(
StorageKeys.groupMembers(userId, groupId)
)
if (!cached || cached.length === 0) {
return null
}
// 把 IDB 拿到的成员落到对应 group
const group = this.getGroup(groupId)
if (!group) {
// group 还没就位:仅 in-memory 占位name='' 表示未知),不调 upsertGroup —— 避免把假名灌进 conversation.name + groups IDB 桶;
// 后续,等 fetchGroups 浅合并时,被真名覆盖
this.groups.push({
id: groupId,
name: '',
members: cached,
memberCount: cached.length,
membersLoaded: true
})
} else {
group.members = cached
group.memberCount = cached.length
group.membersLoaded = true
}
return cached
} catch (e) {
console.warn('[IM groupStore] 本地群成员缓存读取失败', { groupId }, e)
return null
}
},
/** 整桶持久化指定群成员 */
saveGroupMembers(groupId: number): void {
const userId = getCurrentUserId()
if (!userId) {
return
}
const members = this.getGroup(groupId)?.members
if (!members) {
return
}
setQuietly(
StorageKeys.groupMembers(userId, groupId),
members,
`[IM groupStore] 本地群成员缓存写入失败 (groupId=${groupId})`
)
},
// ==================== 远端拉取 ====================
/** 拉取群列表;同步刷新对应群聊会话的展示名 / 头像 + 落 IDB */
async fetchGroups(force = false) {
if (this.loaded && !force) {
return
}
// 拉取当前登录用户加入的所有群(不带成员;成员按需再走 fetchGroupMembers
const list = await apiGetMyGroupList()
const fresh = (list || []).map(convertGroup)
// 合并而非全量替换silent / groupRemark / 成员缓存这些字段不在 ImGroupRespVO 里,得从旧 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
}
return {
...group,
members: existing.members,
memberCount: existing.memberCount ?? group.memberCount,
silent: existing.silent ?? group.silent,
groupRemark: existing.groupRemark,
membersLoaded: existing.membersLoaded
}
})
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.saveGroups()
},
/** 单群刷新:用 /im/group/get 拉一份最新元数据再 upsert常用于 GROUP_UPDATE 推送后或手动 reload */
async fetchGroupInfo(groupId: number) {
try {
const data = await apiGetGroup(groupId)
if (!data) {
return
}
this.upsertGroup(convertGroup(data))
} catch (e) {
console.warn('[IM groupStore] fetchGroupInfo 失败', e)
}
},
/** 按群拉取成员in-memory 缓存 + 并发去重force=true 强刷)+ 落 IDB */
fetchGroupMembers(groupId: number, force = false): Promise<GroupMember[]> {
// in-memory "完整"加载过才命中——单成员补齐写入的 partial members 不在此返回membersLoaded=false
const cached = this.getGroup(groupId)
if (cached && cached.members && cached.membersLoaded && !force) {
return Promise.resolve(cached.members)
}
// 未登录:不发起请求也不登记 in-flight避免污染单飞表
const requestUserId = getCurrentUserId()
if (!requestUserId) {
return Promise.resolve([])
}
// 同 (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)
let meRaw: ImGroupMemberRespVO | 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避免 fetchGroups 已并发写入真实 group 的 race
const group = this.getGroup(groupId)
const isPlaceholder = !group
let groupFieldsChanged = false
if (!group) {
// group 还没就位:仅 in-memory push 占位name='' 表示未知),不调 upsertGroup——避免把假名灌进 conversation.name + groups IDB 桶;
// 后续,等 fetchGroups 浅合并时,被真名覆盖
this.groups.push({
id: groupId,
name: '',
members,
memberCount: members.length,
silent,
groupRemark,
membersLoaded: true
})
} else {
group.members = members
group.memberCount = members.length
group.membersLoaded = true
// silent / groupRemark 任一变化才同步到 conversation 和 IDBgroupRemark 变化要顺带刷会话名
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
})
}
}
// groups 桶仅在 user-per-group 字段实际变化时写——避免一次批量进群引发多次整桶重写
this.saveGroupMembers(groupId)
if (!isPlaceholder && groupFieldsChanged) {
this.saveGroups()
}
return members
// 无论成功 / 失败都要从单飞表清掉,否则后续同 group 请求永远拿到这个 stale Promise
})().finally(() => pendingMemberFetches.delete(key))
// 把 Promise 登记进单飞表,让此后短时间内的同 (userId, groupId) 请求复用
pendingMemberFetches.set(key, promise)
return promise
},
/**
* 按 (groupId, memberUserId) 单成员补齐——deriveLastSenderDisplayName 兜底场景用
*
* 跟 fetchGroupMembers 区别:只拉这一个成员,不动 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)
}
// 同 (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
}
const member = convertGroupMember(data, groupId)
// 把这一条 upsert 进 group.members 仅供 in-memory 渲染兜底group 还没就位则用 placeholder
// 注意:不写 IDB——成员桶语义是"全量",存"1 人桶"会污染下次冷启动的 loadGroupMembers
const group = this.getGroup(groupId)
if (!group) {
// memberCount 不设:后续 fetchGroups 合并 `existing.memberCount ?? fresh.memberCount` 时,
// 占位值会顶替真实值fresh 不带 memberCount等 fetchGroupMembers 跑过才能拿到真实数
this.groups.push({
id: groupId,
name: '',
members: [member]
})
} else {
const memberList = group.members ?? []
const index = memberList.findIndex((m) => m.userId === memberUserId)
if (index >= 0) {
memberList[index] = member
} else {
memberList.push(member)
}
group.members = memberList
}
return member
})().finally(() => pendingSingleMemberFetches.delete(key))
pendingSingleMemberFetches.set(key, promise)
return promise
},
/** 按 id 插入或合并群(命中则浅合并保留旧字段,未命中则追加),同步把展示名 / 头像 / 免打扰推到对应会话 */
upsertGroup(group: Group) {
const index = this.groups.findIndex((g) => g.id === group.id)
if (index >= 0) {
this.groups[index] = { ...this.groups[index], ...group }
} else {
this.groups.push(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
})
// 持久化到 IDBfire-and-forget
this.saveGroups()
},
/** 本地移除(由 WebSocket GROUP_DEL 事件触发) */
removeGroup(id: number) {
// 群解散是硬删(区别于好友删除的软删保留记录);级联清群聊会话避免列表里留死群
this.groups = this.groups.filter((g) => g.id !== id)
const conversationStore = useConversationStore()
conversationStore.removeGroupConversation(id)
this.saveGroups()
// 群解散后顺手删 IDB 里该群的成员桶——这桶仅靠 groupId 索引,不删会一直留在 IDB 占空间
const userId = getCurrentUserId()
if (userId) {
removeQuietly(
StorageKeys.groupMembers(userId, id),
`[IM groupStore] 群成员缓存删除失败 (groupId=${id})`
)
}
},
/** 切换免打扰:推后端 + 落本地 */
async setSilent(id: number, silent: boolean) {
await apiUpdateGroupMember({ groupId: id, silent })
const group = this.getGroup(id)
if (!group) {
return
}
group.silent = silent
this.saveGroups()
},
/** 批量更新群成员角色;本地不命中则忽略,等 fetchGroupMembers 兜底 */
updateMembersRole(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
}
},
/** 群主转让:群表 ownerUserId 改为新值;旧群主 role → NORMAL新群主 role → OWNER */
transferOwner(groupId: number, oldOwnerId: number, newOwnerId: number) {
const group = this.getGroup(groupId)
if (!group) {
return
}
if (group.ownerUserId !== newOwnerId) {
group.ownerUserId = newOwnerId
}
this.updateMembersRole(groupId, [oldOwnerId], ImGroupMemberRole.NORMAL)
this.updateMembersRole(groupId, [newOwnerId], ImGroupMemberRole.OWNER)
this.saveGroups()
},
/** 本地剔除群成员GROUP_MEMBER_QUIT / KICK 事件);不命中则等 fetchGroupMembers 兜底 */
removeMembersLocal(groupId: number, userIds: number[]) {
const group = this.getGroup(groupId)
if (!group?.members?.length || !userIds.length) {
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.saveGroupMembers(groupId)
},
/** 本地更新群成员的 displayUserNameGROUP_MEMBER_NICKNAME_UPDATE 事件);不命中则等 fetchGroupMembers 兜底 */
updateMemberDisplayUserName(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.saveGroupMembers(groupId)
},
/** 局部更新群字段name / notice / avatar 等);未命中本地缓存时静默忽略,等 fetchGroups 兜底;新值跟旧值都相同时跳过响应式 + 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.saveGroups()
},
/**
* 接收 GROUP_* 群广播事件,按 type 分发到对应私有 action
*
* WebSocket 实时收 + useMessagePuller 离线 pull 都走 conversationStore.insertMessage 旁路调用
* store 里没缓存的群静默忽略,等 fetchGroups 兜底
*/
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 ImMessageType.GROUP_CREATE:
this.applyGroupCreateNotification(groupId, payload)
break
case ImMessageType.GROUP_NAME_UPDATE:
this.applyGroupNameUpdateNotification(groupId, payload)
break
case ImMessageType.GROUP_NOTICE_UPDATE:
this.applyGroupNoticeUpdateNotification(groupId, payload)
break
case ImMessageType.GROUP_INFO_UPDATE:
this.applyGroupInfoUpdateNotification(groupId, payload)
break
case ImMessageType.GROUP_DISSOLVE:
this.removeGroup(groupId)
break
case ImMessageType.GROUP_MEMBER_INVITE:
this.applyGroupMemberInviteNotification(groupId, payload)
break
case ImMessageType.GROUP_MEMBER_QUIT:
this.applyGroupMemberQuitNotification(groupId, payload)
break
case ImMessageType.GROUP_MEMBER_KICK:
this.applyGroupMemberKickNotification(groupId, payload)
break
case ImMessageType.GROUP_MEMBER_NICKNAME_UPDATE:
this.applyGroupMemberNicknameUpdateNotification(groupId, payload)
break
case ImMessageType.GROUP_ADMIN_ADD:
this.updateMembersRole(groupId, payload.memberUserIds || [], ImGroupMemberRole.ADMIN)
break
case ImMessageType.GROUP_ADMIN_REMOVE:
this.updateMembersRole(groupId, payload.memberUserIds || [], ImGroupMemberRole.NORMAL)
break
case ImMessageType.GROUP_OWNER_TRANSFER:
this.applyGroupOwnerTransferNotification(groupId, payload)
break
case ImMessageType.GROUP_MESSAGE_PIN:
this.applyGroupMessagePinNotification(groupId, payload)
break
case ImMessageType.GROUP_MESSAGE_UNPIN:
this.applyGroupMessageUnpinNotification(groupId, payload)
break
case ImMessageType.GROUP_MEMBER_MUTED:
this.applyGroupMemberMutedNotification(groupId, payload)
break
case ImMessageType.GROUP_MEMBER_CANCEL_MUTED:
this.applyGroupMemberCancelMutedNotification(groupId, payload)
break
case ImMessageType.GROUP_MUTED:
this.updateGroupFields(groupId, { mutedAll: true })
break
case ImMessageType.GROUP_CANCEL_MUTED:
this.updateGroupFields(groupId, { mutedAll: false })
break
case ImMessageType.GROUP_BANNED:
this.updateGroupFields(groupId, { banned: !!payload.banned })
break
}
},
/** 创建群广播:创建者多端同步 + 初始成员 bootstrappayload.memberUserIds 含自己 → 拉群详情 / 成员;本端发起者已经 upsert 过本群,跳过避免双拉 */
applyGroupCreateNotification(groupId: number, payload: GroupNotificationPayload) {
if (!isSelfInPayloadMembers(payload)) {
return
}
const selfUserId = getCurrentUserId()
const selfIsOperator = !!selfUserId && payload.operatorUserId === selfUserId
if (selfIsOperator && this.getGroup(groupId)) {
return
}
this.fetchGroupInfo(groupId).catch(() => undefined)
this.fetchGroupMembers(groupId, true).catch(() => undefined)
},
/** 群名变更:按 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 ?? '' })
},
/** 群信息变更NAME / NOTICE 之外字段,当前承载头像变更) */
applyGroupInfoUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
const fields: Partial<Group> = {}
if (payload.newAvatar) {
fields.avatar = payload.newAvatar
}
if (Object.keys(fields).length > 0) {
this.updateGroupFields(groupId, fields)
}
},
/** 成员加入:被邀请者本端 group 未就位先 fetchGroupInfo bootstrap所有人都刷成员列表新成员 nickname / avatar 不在 payload */
applyGroupMemberInviteNotification(groupId: number, payload: GroupNotificationPayload) {
if (isSelfInPayloadMembers(payload) && !this.getGroup(groupId)) {
this.fetchGroupInfo(groupId).catch(() => undefined)
}
this.fetchGroupMembers(groupId, true).catch(() => undefined)
},
/** 成员退群:退群者本人多端同步走 removeGroup其他成员从本地列表移除 quitter */
applyGroupMemberQuitNotification(groupId: number, payload: GroupNotificationPayload) {
const selfUserId = getCurrentUserId()
if (selfUserId && payload.operatorUserId === selfUserId) {
this.removeGroup(groupId)
} else if (payload.operatorUserId) {
this.removeMembersLocal(groupId, [payload.operatorUserId])
}
},
/** 成员被移出:被踢者本人 removeGroup其他成员从本地列表移除被踢者 */
applyGroupMemberKickNotification(groupId: number, payload: GroupNotificationPayload) {
const memberIds = payload.memberUserIds || []
if (isSelfInPayloadMembers(payload)) {
this.removeGroup(groupId)
} else if (memberIds.length) {
this.removeMembersLocal(groupId, memberIds)
}
},
/** 成员昵称变更:按 operatorUserId 局部更新对应 member.displayUserName */
applyGroupMemberNicknameUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
if (payload.operatorUserId) {
this.updateMemberDisplayUserName(
groupId,
payload.operatorUserId,
payload.displayUserName ?? ''
)
}
},
/** 群主转让:旧群主 → NORMAL新群主 → OWNER */
applyGroupOwnerTransferNotification(groupId: number, payload: GroupNotificationPayload) {
if (payload.operatorUserId && payload.newOwnerUserId) {
this.transferOwner(groupId, payload.operatorUserId, payload.newOwnerUserId)
}
},
/** 群消息置顶:从 payload 直接拿完整消息对象 push 到 pinnedMessages */
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
}
// message 字段名跟 Message 一致(仅 sendTime 需要从 ISO 串转毫秒戳selfSend 按当前用户算)
const newMessage: Message = {
...message,
clientMessageId: message.clientMessageId || '',
sendTime: new Date(message.sendTime).getTime(),
targetId: message.groupId,
selfSend: message.senderId === getCurrentUserId(),
atUserIds: message.atUserIds || [],
receiverUserIds: message.receiverUserIds || []
}
group.pinnedMessages = [...existing, newMessage]
this.saveGroups()
},
/** 群消息取消置顶:按 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.saveGroups()
},
/** 单成员禁言:更新目标成员的 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.saveGroupMembers(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.saveGroupMembers(groupId)
}
},
/** 切账号时仅清 in-memoryIDB 按 userId 分桶天然隔离,回切秒开 */
clear() {
this.groups = []
this.loaded = false
// 单飞表跟 in-memory state 一起重置;旧账号 in-flight 的请求 finally 也会自己 delete key提前清空只是更干脆
pendingMemberFetches.clear()
pendingSingleMemberFetches.clear()
}
}
})
function convertGroup(group: ImGroupRespVO): Group {
return {
id: group.id,
name: group.name,
avatar: group.avatar,
notice: group.notice,
ownerUserId: group.ownerUserId,
pinnedMessages: group.pinnedMessages?.map(convertGroupMessageVO),
mutedAll: group.mutedAll,
banned: group.banned
}
}
/** 后端 ImGroupMessageRespVO -> 前端 Message补 targetId / selfSend / sendTime 等派生字段 */
function convertGroupMessageVO(message: NonNullable<ImGroupRespVO['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: ImGroupMemberRespVO, 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(store)
// dev: 让 Pinia 的 actions 改动支持 HMR免去每次改 store 都要硬刷
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useGroupStore, import.meta.hot))
}