🐛 fix(im): 撤回信号错用 TIP_TEXT,应为 RECALL
parent
505b3b5953
commit
e30e30ea51
|
|
@ -41,7 +41,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
* 1. 置顶优先(top=true 的在前)
|
||||
* 2. 同级别按 lastSendTime 降序
|
||||
*/
|
||||
sortedConversations(state): Conversation[] {
|
||||
getSortedConversations(state): Conversation[] {
|
||||
return [...state.conversations]
|
||||
.filter((c) => !c.deleted)
|
||||
.sort((a, b) => {
|
||||
|
|
@ -54,15 +54,21 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
})
|
||||
},
|
||||
/** 当前会话的消息列表 */
|
||||
activeMessages(state): Message[] {
|
||||
getActiveMessages(state): Message[] {
|
||||
return state.activeConversation?.messages || []
|
||||
},
|
||||
/** 未读总数(免打扰会话不计入)—— 用于 ToolBar 红点 */
|
||||
totalUnread(state): number {
|
||||
getTotalUnread(state): number {
|
||||
return state.conversations
|
||||
.filter((c) => !c.deleted && !c.muted)
|
||||
.reduce((sum, c) => sum + (c.unreadCount || 0), 0)
|
||||
}
|
||||
},
|
||||
/** 查找会话:按 (type, targetId) 组合主键 */
|
||||
getConversation:
|
||||
(state) =>
|
||||
(type: number, targetId: number): Conversation | undefined => {
|
||||
return state.conversations.find((c) => c.type === type && c.targetId === targetId)
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
|
|
@ -122,11 +128,6 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
|
||||
// ==================== 会话查找 / 打开 ====================
|
||||
|
||||
/** 查找会话:按 (type, targetId) 组合主键 */
|
||||
findConversation(type: number, targetId: number): Conversation | undefined {
|
||||
return this.conversations.find((c) => c.type === type && c.targetId === targetId)
|
||||
},
|
||||
|
||||
/**
|
||||
* 打开或创建一个会话,并设为激活
|
||||
*
|
||||
|
|
@ -137,25 +138,25 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
openConversation(
|
||||
targetId: number,
|
||||
type: number,
|
||||
showName: string,
|
||||
showImage: string,
|
||||
name: string,
|
||||
avatar: string,
|
||||
options?: { muted?: boolean }
|
||||
): Conversation {
|
||||
// 按 (type, targetId) 查找已有会话,不存在则新建并插到列表头部
|
||||
let conversation = this.findConversation(type, targetId)
|
||||
let conversation = this.getConversation(type, targetId)
|
||||
if (!conversation) {
|
||||
conversation = this.createEmptyConversation(type, targetId, showName, showImage)
|
||||
conversation = this.createEmptyConversation(type, targetId, name, avatar)
|
||||
if (options?.muted !== undefined) {
|
||||
conversation.muted = options.muted
|
||||
}
|
||||
this.conversations.unshift(conversation)
|
||||
} else {
|
||||
// 已存在会话:用最新元数据刷新 showName / showImage / muted
|
||||
if (showName) {
|
||||
conversation.showName = showName
|
||||
// 已存在会话:用最新元数据刷新 name / avatar / muted
|
||||
if (name) {
|
||||
conversation.name = name
|
||||
}
|
||||
if (showImage) {
|
||||
conversation.showImage = showImage
|
||||
if (avatar) {
|
||||
conversation.avatar = avatar
|
||||
}
|
||||
if (options?.muted !== undefined) {
|
||||
conversation.muted = options.muted
|
||||
|
|
@ -177,12 +178,12 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
},
|
||||
|
||||
/** 创建空会话(抽取公共逻辑,供 insertMessage / openConversation 复用) */
|
||||
createEmptyConversation(type: number, targetId: number, showName: string, showImage: string): Conversation {
|
||||
createEmptyConversation(type: number, targetId: number, name: string, avatar: string): Conversation {
|
||||
return {
|
||||
targetId,
|
||||
type,
|
||||
showName,
|
||||
showImage,
|
||||
name,
|
||||
avatar,
|
||||
lastContent: '',
|
||||
lastSendTime: 0,
|
||||
unreadCount: 0,
|
||||
|
|
@ -201,7 +202,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
|
||||
/** 将某个会话置顶态切换 */
|
||||
setTop(type: number, targetId: number, top: boolean) {
|
||||
const conversation = this.findConversation(type, targetId)
|
||||
const conversation = this.getConversation(type, targetId)
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
|
|
@ -211,7 +212,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
|
||||
/** 设置会话免打扰(本地状态;后端同步由 friendStore / groupStore + /muted API 负责) */
|
||||
setMuted(type: number, targetId: number, muted: boolean) {
|
||||
const conversation = this.findConversation(type, targetId)
|
||||
const conversation = this.getConversation(type, targetId)
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
|
|
@ -221,7 +222,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
|
||||
/** 删除会话(软删:标记 deleted=true,持久化时过滤)*/
|
||||
removeConversation(type: number, targetId: number) {
|
||||
const conversation = this.findConversation(type, targetId)
|
||||
const conversation = this.getConversation(type, targetId)
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
|
|
@ -252,17 +253,17 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
* 4. 收尾:更新游标 + 持久化
|
||||
*/
|
||||
insertMessage(
|
||||
conversationInfo: { type: number; targetId: number; showName: string; showImage: string },
|
||||
conversationInfo: { type: number; targetId: number; name: string; avatar: string },
|
||||
messageInfo: Message
|
||||
) {
|
||||
// 1.1 查找或自动创建会话
|
||||
let conversation = this.findConversation(conversationInfo.type, conversationInfo.targetId)
|
||||
let conversation = this.getConversation(conversationInfo.type, conversationInfo.targetId)
|
||||
if (!conversation) {
|
||||
conversation = this.createEmptyConversation(
|
||||
conversationInfo.type,
|
||||
conversationInfo.targetId,
|
||||
conversationInfo.showName,
|
||||
conversationInfo.showImage
|
||||
conversationInfo.name,
|
||||
conversationInfo.avatar
|
||||
)
|
||||
this.conversations.unshift(conversation)
|
||||
}
|
||||
|
|
@ -396,7 +397,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
clientMessageId: string,
|
||||
updates: Partial<Message>
|
||||
) {
|
||||
const conversation = this.findConversation(conversationType, targetId)
|
||||
const conversation = this.getConversation(conversationType, targetId)
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
|
|
@ -422,7 +423,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
senderNickName: string,
|
||||
selfSend: boolean
|
||||
) {
|
||||
const conversation = this.findConversation(conversationType, targetId)
|
||||
const conversation = this.getConversation(conversationType, targetId)
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
|
|
@ -453,7 +454,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
readCount?: number
|
||||
receiptStatus?: number
|
||||
}) {
|
||||
const conversation = this.findConversation(options.conversationType, options.targetId)
|
||||
const conversation = this.getConversation(options.conversationType, options.targetId)
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
|
|
@ -486,7 +487,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
targetId: number,
|
||||
key: { id?: number; clientMessageId?: string }
|
||||
) {
|
||||
const conversation = this.findConversation(conversationType, targetId)
|
||||
const conversation = this.getConversation(conversationType, targetId)
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
|
|
@ -551,43 +552,29 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
this.saveToStorage()
|
||||
},
|
||||
|
||||
/** 根据 friendStore 最新的好友信息同步对应私聊会话的展示名 / 头像 / 免打扰 */
|
||||
updateConversationFromFriend(friendId: number, info: { nickName?: string; showImage?: string; muted?: boolean }) {
|
||||
const conversation = this.findConversation(ImConversationType.PRIVATE, friendId)
|
||||
/**
|
||||
* 同步会话的展示元数据(name / avatar / muted)
|
||||
*
|
||||
* 调用方负责把好友 / 群的信息整理成 Conversation 视角的字段:
|
||||
* - 私聊:name = friend.nickname;avatar = friend.avatar
|
||||
* - 群聊:name = group.name(或叠加 displayGroupName);avatar = group.avatar
|
||||
*/
|
||||
updateConversation(
|
||||
type: number,
|
||||
targetId: number,
|
||||
info: { name?: string; avatar?: string; muted?: boolean }
|
||||
) {
|
||||
const conversation = this.getConversation(type, targetId)
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
let changed = false
|
||||
if (info.nickName && conversation.showName !== info.nickName) {
|
||||
conversation.showName = info.nickName
|
||||
if (info.name && conversation.name !== info.name) {
|
||||
conversation.name = info.name
|
||||
changed = true
|
||||
}
|
||||
if (info.showImage !== undefined && conversation.showImage !== info.showImage) {
|
||||
conversation.showImage = info.showImage || ''
|
||||
changed = true
|
||||
}
|
||||
if (info.muted !== undefined && conversation.muted !== info.muted) {
|
||||
conversation.muted = info.muted
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
this.saveToStorage()
|
||||
}
|
||||
},
|
||||
|
||||
/** 根据 groupStore 最新的群信息同步对应群聊会话的展示名 / 头像 / 免打扰 */
|
||||
updateConversationFromGroup(groupId: number, info: { name?: string; showImage?: string; muted?: boolean }) {
|
||||
const conversation = this.findConversation(ImConversationType.GROUP, groupId)
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
let changed = false
|
||||
if (info.name && conversation.showName !== info.name) {
|
||||
conversation.showName = info.name
|
||||
changed = true
|
||||
}
|
||||
if (info.showImage !== undefined && conversation.showImage !== info.showImage) {
|
||||
conversation.showImage = info.showImage || ''
|
||||
if (info.avatar !== undefined && conversation.avatar !== info.avatar) {
|
||||
conversation.avatar = info.avatar || ''
|
||||
changed = true
|
||||
}
|
||||
if (info.muted !== undefined && conversation.muted !== info.muted) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { store } from '@/store'
|
||||
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import {
|
||||
getMyFriendList as apiGetMyFriendList,
|
||||
getFriend as apiGetFriend,
|
||||
addFriend as apiAddFriend,
|
||||
deleteFriend as apiDeleteFriend,
|
||||
updateFriend as apiUpdateFriend,
|
||||
type ImFriendRespVO
|
||||
} from '@/api/im/friend'
|
||||
import { useConversationStore } from './conversationStore'
|
||||
import { ImConversationType } from '../../utils/constants'
|
||||
import type { Friend } from '../types'
|
||||
|
||||
/**
|
||||
* IM 好友 Store
|
||||
*
|
||||
* 负责:
|
||||
* - 拉取 / 缓存当前登录用户的好友列表
|
||||
* - 加好友 / 删好友(走后端 API + 本地乐观同步)
|
||||
* - 被 ConversationItem / FriendPage / MessageInput 等多处消费
|
||||
*/
|
||||
export const useFriendStore = defineStore('imFriendStore', {
|
||||
state: () => ({
|
||||
friends: [] as Friend[],
|
||||
loaded: false
|
||||
}),
|
||||
|
||||
getters: {
|
||||
getFriend:
|
||||
(state) =>
|
||||
(friendUserId: number): Friend | undefined => {
|
||||
return state.friends.find((f) => f.friendUserId === friendUserId)
|
||||
},
|
||||
getActiveFriends: (state): Friend[] => {
|
||||
return state.friends.filter((f) => f.status !== CommonStatusEnum.DISABLE)
|
||||
},
|
||||
isFriend() {
|
||||
return (friendUserId: number): boolean => {
|
||||
const entry = this.getFriend(friendUserId)
|
||||
return !!entry && entry.status !== CommonStatusEnum.DISABLE
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
/** 从后端拉取并覆盖本地列表;同步刷新对应私聊会话的展示名 / 头像 */
|
||||
async loadFriends(force = false) {
|
||||
if (this.loaded && !force) {
|
||||
return
|
||||
}
|
||||
const list = await apiGetMyFriendList()
|
||||
this.friends = (list || []).map(toFriend)
|
||||
this.loaded = true
|
||||
// 同步 conversationStore 私聊会话的展示名 / 头像 / 免打扰
|
||||
const conversationStore = useConversationStore()
|
||||
for (const f of this.friends) {
|
||||
conversationStore.updateConversation(ImConversationType.PRIVATE, f.friendUserId, {
|
||||
name: f.nickname,
|
||||
avatar: f.avatar,
|
||||
muted: f.muted
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/** 按 friendUserId 获取详情并合并到本地(保证 nickname / avatar 最新) */
|
||||
async loadFriendInfo(friendUserId: number) {
|
||||
try {
|
||||
const data = await apiGetFriend(friendUserId)
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
this.upsertFriend(toFriend(data))
|
||||
} catch (e) {
|
||||
console.warn('[IM friendStore] loadFriendInfo 失败', e)
|
||||
}
|
||||
},
|
||||
|
||||
/** 添加好友:后端双向建立关系后,本地占位插入(服务端返回后可 loadFriends 刷新) */
|
||||
async addFriend(friendUserId: number, preview?: Partial<Friend>) {
|
||||
await apiAddFriend(friendUserId)
|
||||
if (preview) {
|
||||
this.upsertFriend({
|
||||
friendUserId,
|
||||
nickname: preview.nickname || String(friendUserId),
|
||||
avatar: preview.avatar,
|
||||
status: CommonStatusEnum.ENABLE
|
||||
})
|
||||
} else {
|
||||
await this.loadFriendInfo(friendUserId)
|
||||
}
|
||||
},
|
||||
|
||||
/** 删除好友(保留墓碑记录,同时级联清理本地私聊会话) */
|
||||
async deleteFriend(friendUserId: number) {
|
||||
await apiDeleteFriend(friendUserId)
|
||||
this.removeFriend(friendUserId)
|
||||
},
|
||||
|
||||
/** 本地合并 / 新增某个好友(WebSocket 事件 & 手动刷新都用) */
|
||||
upsertFriend(friend: Friend) {
|
||||
// TODO DONE @AI:index
|
||||
// TODO DONE @AI:注释
|
||||
// 按 friendUserId 查已有记录下标:>=0 命中则覆盖合并,<0 则追加
|
||||
const index = this.friends.findIndex((f) => f.friendUserId === friend.friendUserId)
|
||||
if (index >= 0) {
|
||||
this.friends[index] = {
|
||||
...this.friends[index],
|
||||
...friend,
|
||||
status: friend.status ?? CommonStatusEnum.ENABLE
|
||||
}
|
||||
} else {
|
||||
this.friends.push({
|
||||
...friend,
|
||||
status: friend.status ?? CommonStatusEnum.ENABLE
|
||||
})
|
||||
}
|
||||
// 同步对应私聊会话的展示
|
||||
const conversationStore = useConversationStore()
|
||||
conversationStore.updateConversation(ImConversationType.PRIVATE, friend.friendUserId, {
|
||||
name: friend.nickname,
|
||||
avatar: friend.avatar,
|
||||
muted: friend.muted
|
||||
})
|
||||
},
|
||||
|
||||
/** 本地标记删除(WebSocket FRIEND_DEL 事件触发;同时级联清私聊会话) */
|
||||
removeFriend(friendUserId: number) {
|
||||
// TODO DONE @AI:变量叫 friend
|
||||
// 标记墓碑:保留记录但置为 DISABLE,避免后续误判"陌生人"
|
||||
const friend = this.getFriend(friendUserId)
|
||||
if (friend) {
|
||||
friend.status = CommonStatusEnum.DISABLE
|
||||
friend.deleteTime = new Date().toISOString()
|
||||
}
|
||||
// TODO DONE @AI:注释
|
||||
// 级联清理:把对应的私聊会话也软删,避免会话列表里留着已删好友
|
||||
const conversationStore = useConversationStore()
|
||||
conversationStore.removePrivateConversation(friendUserId)
|
||||
},
|
||||
|
||||
/** 切换免打扰 */
|
||||
async setMuted(friendUserId: number, muted: boolean) {
|
||||
await apiUpdateFriend({ friendUserId, muted })
|
||||
// TODO DONE @AI:变量叫 friend
|
||||
const friend = this.getFriend(friendUserId)
|
||||
if (friend) {
|
||||
friend.muted = muted
|
||||
}
|
||||
},
|
||||
|
||||
/** 切换用户时清空 */
|
||||
clear() {
|
||||
this.friends = []
|
||||
this.loaded = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function toFriend(vo: ImFriendRespVO): Friend {
|
||||
return {
|
||||
id: vo.id,
|
||||
friendUserId: vo.friendUserId,
|
||||
nickname: vo.nickname || String(vo.friendUserId),
|
||||
avatar: vo.avatar,
|
||||
muted: !!vo.muted,
|
||||
status: vo.status,
|
||||
addTime: vo.addTime,
|
||||
deleteTime: vo.deleteTime
|
||||
}
|
||||
}
|
||||
|
||||
export const useFriendStoreWithOut = () => useFriendStore(store)
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { store } from '@/store'
|
||||
|
||||
import {
|
||||
getMyGroupList as apiGetMyGroupList,
|
||||
getGroup as apiGetGroup,
|
||||
type ImGroupRespVO
|
||||
} from '@/api/im/group'
|
||||
import {
|
||||
getGroupMemberList as apiGetGroupMemberList,
|
||||
type ImGroupMemberRespVO
|
||||
} from '@/api/im/group/member'
|
||||
import { useConversationStore } from './conversationStore'
|
||||
import { ImConversationType } from '../../utils/constants'
|
||||
import type { Group, GroupMember } from '../types'
|
||||
|
||||
/**
|
||||
* IM 群 Store
|
||||
*
|
||||
* 负责:
|
||||
* - 拉取 / 缓存当前登录用户加入的群列表
|
||||
* - 按 groupId 懒加载群成员(供 ChatGroupSide / MentionPicker / MessageReadStatus 消费)
|
||||
* - 成员"已读 / 未读"等聚合查询由 MessageReadStatus 另行组合
|
||||
*/
|
||||
export const useGroupStore = defineStore('imGroupStore', {
|
||||
state: () => ({
|
||||
groups: [] as Group[],
|
||||
loaded: false
|
||||
}),
|
||||
|
||||
getters: {
|
||||
getGroup: (state) => (id: number): Group | undefined => {
|
||||
return state.groups.find((g) => g.id === id)
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
/** 拉取群列表;同步刷新对应群聊会话的展示名 / 头像 */
|
||||
async loadGroups(force = false) {
|
||||
if (this.loaded && !force) {
|
||||
return
|
||||
}
|
||||
// TODO DONE @AI:注释下
|
||||
// 拉取当前登录用户加入的所有群(不带成员;成员按需再走 loadGroupMembers)
|
||||
const list = await apiGetMyGroupList()
|
||||
this.groups = (list || []).map(toGroup)
|
||||
this.loaded = true
|
||||
const conversationStore = useConversationStore()
|
||||
for (const g of this.groups) {
|
||||
conversationStore.updateConversation(ImConversationType.GROUP, g.id, {
|
||||
name: g.name,
|
||||
avatar: g.avatar,
|
||||
muted: g.muted
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/** 刷新单个群详情 */
|
||||
async loadGroupInfo(groupId: number) {
|
||||
try {
|
||||
const data = await apiGetGroup(groupId)
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
// TODO DONE @AI:group
|
||||
this.upsertGroup(toGroup(data))
|
||||
} catch (e) {
|
||||
console.warn('[IM groupStore] loadGroupInfo 失败', e)
|
||||
}
|
||||
},
|
||||
|
||||
/** 按群拉取成员(带缓存,force=true 强制刷新) */
|
||||
async loadGroupMembers(groupId: number, force = false): Promise<GroupMember[]> {
|
||||
// TODO DONE @AI:group
|
||||
// 命中缓存:群已加载且成员列表已就绪,直接返回(force=true 时强制刷)
|
||||
const group = this.getGroup(groupId)
|
||||
if (group && group.members && !force) {
|
||||
return group.members
|
||||
}
|
||||
|
||||
// TODO DONE @AI:注释;
|
||||
// 拉取该群所有成员(聚合自 AdminUser,含 nickname / avatar / displayUserName)
|
||||
const list = await apiGetGroupMemberList(groupId)
|
||||
const members = (list || []).map((m) => toGroupMember(m, groupId))
|
||||
// 成员列表可能在群列表之前触发,此时需要占位一个 group
|
||||
if (!group) {
|
||||
this.upsertGroup({
|
||||
id: groupId,
|
||||
name: String(groupId),
|
||||
members,
|
||||
memberCount: members.length
|
||||
})
|
||||
} else {
|
||||
group.members = members
|
||||
group.memberCount = members.length
|
||||
}
|
||||
return members
|
||||
},
|
||||
|
||||
upsertGroup(group: Group) {
|
||||
// TODO DONE @AI:index
|
||||
// 按 id 查已有记录下标:>=0 命中则覆盖合并,<0 则追加
|
||||
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)
|
||||
}
|
||||
// 同步对应群聊会话的展示
|
||||
const conversationStore = useConversationStore()
|
||||
conversationStore.updateConversation(ImConversationType.GROUP, group.id, {
|
||||
name: group.name,
|
||||
avatar: group.avatar,
|
||||
muted: group.muted
|
||||
})
|
||||
},
|
||||
|
||||
/** 本地移除(WebSocket GROUP_DEL 事件触发;同时级联清群聊会话) */
|
||||
removeGroup(id: number) {
|
||||
// TODO DONE @AI:注释
|
||||
// 直接从本地列表里移除(群解散是硬删,不留墓碑,区别于好友的软删)
|
||||
this.groups = this.groups.filter((g) => g.id !== id)
|
||||
// 级联清理:对应群聊会话也软删,避免会话列表里留着已解散的群
|
||||
const conversationStore = useConversationStore()
|
||||
conversationStore.removeGroupConversation(id)
|
||||
},
|
||||
|
||||
/** 切换免打扰(仅本地状态;后端 /im/group/update 接入 muted 字段后再补) */
|
||||
setMuted(id: number, muted: boolean) {
|
||||
// TODO DONE @AI:注释
|
||||
// 在本地 group 上直接打 muted 标记;conversationStore 的会话级 muted 由 ConversationItem 单独 setMuted 写
|
||||
const group = this.getGroup(id)
|
||||
if (group) {
|
||||
group.muted = muted
|
||||
}
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.groups = []
|
||||
this.loaded = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function toGroup(vo: ImGroupRespVO): Group {
|
||||
return {
|
||||
id: vo.id,
|
||||
name: vo.name || '',
|
||||
avatar: vo.avatar,
|
||||
notice: vo.notice,
|
||||
ownerUserId: vo.ownerUserId
|
||||
}
|
||||
}
|
||||
|
||||
function toGroupMember(m: ImGroupMemberRespVO, groupId: number): GroupMember {
|
||||
return {
|
||||
id: m.id,
|
||||
userId: m.userId,
|
||||
groupId,
|
||||
nickname: m.nickname || String(m.userId),
|
||||
avatar: m.avatar,
|
||||
displayUserName: m.displayUserName,
|
||||
displayGroupName: m.displayGroupName,
|
||||
status: m.status
|
||||
}
|
||||
}
|
||||
|
||||
export const useGroupStoreWithOut = () => useGroupStore(store)
|
||||
|
|
@ -0,0 +1,566 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { store } from '@/store'
|
||||
import { getRefreshToken } from '@/utils/auth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
import { ImWebSocketMessageType, ImMessageType, ImConversationType } from '../../utils/constants'
|
||||
import { parseRecallMessageId, playAudioTip } from '../../utils/message'
|
||||
import { useConversationStore } from './conversationStore'
|
||||
import { useFriendStore } from './friendStore'
|
||||
import { useGroupStore } from './groupStore'
|
||||
import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/message/private'
|
||||
import { readGroupMessages as apiReadGroupMessages } from '@/api/im/message/group'
|
||||
import type {
|
||||
WebSocketFrame,
|
||||
ImPrivateMessageDTO,
|
||||
ImGroupMessageDTO,
|
||||
Message
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* IM WebSocket Store
|
||||
*
|
||||
* 职责(不只是连通信,也是后端 IM 事件的统一入口 → 联动 conversationStore / friendStore / groupStore):
|
||||
*
|
||||
* 1. 链路管理:建连 / 断连 / 心跳保活 / 自动重连
|
||||
* 2. 帧分发:dispatchFrame → dispatchPrivateFrame / dispatchGroupFrame,按 ImMessageType 再分流
|
||||
* 3. 缓冲:初始化加载期(conversationStore.loading=true)暂存消息,等 pull 完成后由 useMessagePuller 调 flushBuffer 回放
|
||||
* 4. 事件处理(按类型分发到对应 handle*,联动 conversation / friend / group store):
|
||||
* - 普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT):入库 + 当前会话自动已读 / 提示音
|
||||
* - 已读 / 回执(READ / RECEIPT):多端已读同步、对方读后回执
|
||||
* - 好友变更(FRIEND_ADD / DELETE / UPDATE):同步 friendStore + 级联刷新私聊会话
|
||||
* - 群变更(GROUP_CREATE / UPDATE / DELETE / MEMBER_UPDATE):同步 groupStore + 级联刷新群聊会话
|
||||
*/
|
||||
export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||
state: () => ({
|
||||
socket: null as WebSocket | null,
|
||||
isConnected: false,
|
||||
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
heartbeatTimer: null as ReturnType<typeof setInterval> | null,
|
||||
messageBuffer: [] as Array<
|
||||
| { kind: 'private'; payload: ImPrivateMessageDTO }
|
||||
| { kind: 'group'; payload: ImGroupMessageDTO }
|
||||
> // 初始化加载期内,先把普通消息丢进缓冲区,pull 完成后再一次性回放
|
||||
}),
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* 取出缓冲区消息并清空(由 useMessagePuller 在 pull 完成后调用,统一回放给 conversationStore)
|
||||
* 配合 messageBuffer 实现:在 conversationStore.loading 期间收到的 WS 消息先暂存,避免和 pull 的 minId 游标打架
|
||||
*/
|
||||
flushBuffer() {
|
||||
const msgs = [...this.messageBuffer]
|
||||
this.messageBuffer = []
|
||||
return msgs
|
||||
},
|
||||
|
||||
/**
|
||||
* 连接 WebSocket
|
||||
* 复用 yudao 内置 /infra/ws 通道,后端通过 sendObject(type, content) 下发
|
||||
*/
|
||||
connect() {
|
||||
// 鉴权用 refreshToken(生命周期更长;access token 过期后服务端会通过 frame 通知重登)
|
||||
const refreshToken = getRefreshToken()
|
||||
if (!refreshToken) {
|
||||
console.warn('[IM WS] refreshToken 为空,跳过连接')
|
||||
return
|
||||
}
|
||||
const url = `${this.buildWsUrl()}/infra/ws?token=${refreshToken}`
|
||||
this.socket = new WebSocket(url)
|
||||
|
||||
// 连接建立:标记上线 + 启动心跳保活
|
||||
this.socket.onopen = () => {
|
||||
this.isConnected = true
|
||||
console.log('[IM WS] connected')
|
||||
this.startHeartbeat()
|
||||
}
|
||||
|
||||
// 收到帧:'pong' 是心跳应答直接吞掉;其余按 WebSocketFrame 解析后交给 dispatchFrame 分流
|
||||
this.socket.onmessage = (event) => {
|
||||
if (event.data === 'pong') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const frame = JSON.parse(event.data) as WebSocketFrame
|
||||
this.dispatchFrame(frame)
|
||||
} catch (e) {
|
||||
console.error('[IM WS] message parse error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端关闭 / 网络断:标记下线,3 秒后自动重连
|
||||
this.socket.onclose = () => {
|
||||
this.isConnected = false
|
||||
console.log('[IM WS] disconnected')
|
||||
this.reconnect()
|
||||
}
|
||||
|
||||
// 异常同样走重连(onerror 后通常 onclose 也会触发,reconnect 内部已防重)
|
||||
this.socket.onerror = (error) => {
|
||||
console.error('[IM WS] error:', error)
|
||||
this.isConnected = false
|
||||
this.reconnect()
|
||||
}
|
||||
},
|
||||
|
||||
/** 拼接 WebSocket 基础地址 */
|
||||
buildWsUrl(): string {
|
||||
// VITE_BASE_URL 可能是 http:// 或 https:// 开头,替换成 ws:// 或 wss://;如果没配置,就用当前页面的协议 + host
|
||||
const baseUrl = (import.meta as any).env?.VITE_BASE_URL as string | undefined
|
||||
if (baseUrl && baseUrl.length > 0) {
|
||||
return baseUrl.replace(/^http/, 'ws')
|
||||
}
|
||||
// 当前页面协议 + host(如 http://localhost:8080),替换成 ws://localhost:8080
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
return `${protocol}//${host}`
|
||||
},
|
||||
|
||||
/**
|
||||
* 按帧 type 分发:外层只有私聊 / 群聊两个通道,其它事件(已读、回执、好友 / 群变更)
|
||||
* 由各自 dispatchXxxFrame 按 payload.type(ImMessageType)再分流
|
||||
*/
|
||||
dispatchFrame(frame: WebSocketFrame) {
|
||||
const content = this.safeParse(frame.content)
|
||||
if (!content) {
|
||||
return
|
||||
}
|
||||
switch (frame.type) {
|
||||
case ImWebSocketMessageType.PRIVATE_MESSAGE:
|
||||
this.dispatchPrivateFrame(content as ImPrivateMessageDTO)
|
||||
break
|
||||
case ImWebSocketMessageType.GROUP_MESSAGE:
|
||||
this.dispatchGroupFrame(content as ImGroupMessageDTO)
|
||||
break
|
||||
default:
|
||||
console.debug('[IM WS] 未识别事件', frame)
|
||||
}
|
||||
},
|
||||
|
||||
/** content 既可能已是对象也可能是 JSON 字符串(后端用 Map 序列化下发) */
|
||||
safeParse(raw: unknown): Record<string, any> | null {
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
if (typeof raw === 'object') {
|
||||
return raw as Record<string, any>
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw as string)
|
||||
} catch (e) {
|
||||
console.error('[IM WS] content 解析失败', e)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 普通消息 ====================
|
||||
|
||||
/**
|
||||
* 私聊统一帧分发:按 payload.type(ImMessageType)分到已读 / 回执 / 好友变更 / 普通消息
|
||||
*
|
||||
* 对应后端 ImPrivateMessageDTO 的 ofRead / ofReceipt / ofFriendAdd / ofFriendDelete / ofFriendUpdate / ofSend
|
||||
*/
|
||||
dispatchPrivateFrame(websocketMessage: ImPrivateMessageDTO) {
|
||||
switch (websocketMessage.type) {
|
||||
case ImMessageType.READ:
|
||||
this.handlePrivateRead(websocketMessage)
|
||||
break
|
||||
case ImMessageType.RECEIPT:
|
||||
this.handlePrivateReceipt(websocketMessage)
|
||||
break
|
||||
case ImMessageType.FRIEND_ADD:
|
||||
this.handleFriendAdd(websocketMessage)
|
||||
break
|
||||
case ImMessageType.FRIEND_DELETE:
|
||||
this.handleFriendDelete(websocketMessage)
|
||||
break
|
||||
case ImMessageType.FRIEND_UPDATE:
|
||||
this.handleFriendUpdate(websocketMessage)
|
||||
break
|
||||
default:
|
||||
// TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT 等普通消息
|
||||
this.handlePrivateMessage(websocketMessage)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 群聊统一帧分发:按 payload.type(ImMessageType)分到已读 / 回执 / 群变更 / 普通消息
|
||||
*
|
||||
* 对应后端 ImGroupMessageDTO 的 ofRead / ofReceipt / ofGroupCreate / ofGroupUpdate / ofGroupDelete / ofGroupMemberUpdate / ofSend
|
||||
*/
|
||||
dispatchGroupFrame(websocketMessage: ImGroupMessageDTO) {
|
||||
switch (websocketMessage.type) {
|
||||
case ImMessageType.READ:
|
||||
this.handleGroupRead(websocketMessage)
|
||||
break
|
||||
case ImMessageType.RECEIPT:
|
||||
this.handleGroupReceipt(websocketMessage)
|
||||
break
|
||||
case ImMessageType.GROUP_CREATE:
|
||||
this.handleGroupCreate(websocketMessage)
|
||||
break
|
||||
case ImMessageType.GROUP_UPDATE:
|
||||
this.handleGroupUpdate(websocketMessage)
|
||||
break
|
||||
case ImMessageType.GROUP_DELETE:
|
||||
this.handleGroupDelete(websocketMessage)
|
||||
break
|
||||
case ImMessageType.GROUP_MEMBER_UPDATE:
|
||||
this.handleGroupMemberUpdate(websocketMessage)
|
||||
break
|
||||
default:
|
||||
// TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT 等普通消息
|
||||
this.handleGroupMessage(websocketMessage)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 私聊普通消息(TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT)入库 + 自动已读
|
||||
*
|
||||
* 流程:
|
||||
* 1. 离线加载期缓冲(避开与 pull 回填的竞态)
|
||||
* 2. 计算 selfSend / peerId 维度,拉好友信息回填展示字段
|
||||
* 3. 撤回 TIP 短路:转走 applyRecall,不进消息列表
|
||||
* 4. 构造前端 Message,插入到对应私聊会话
|
||||
* 5. 当前会话激活时自动上报已读;否则非免打扰响提示音
|
||||
*/
|
||||
handlePrivateMessage(websocketMessage: ImPrivateMessageDTO) {
|
||||
const conversationStore = useConversationStore()
|
||||
// 1. 离线加载期间先缓冲,等 pull 完成后再统一回放,避免重复或顺序错乱
|
||||
if (conversationStore.loading) {
|
||||
this.messageBuffer.push({ kind: 'private', payload: websocketMessage })
|
||||
return
|
||||
}
|
||||
|
||||
// 2. selfSend / peerId:自己发的消息属于「发给 receiverId 的会话」,别人发的属于「发送者的会话」
|
||||
const userStore = useUserStore()
|
||||
const friendStore = useFriendStore()
|
||||
const currentUserId = Number(userStore.getUser?.id) || 0
|
||||
const selfSend = websocketMessage.senderId === currentUserId
|
||||
const peerId = selfSend ? websocketMessage.receiverId : websocketMessage.senderId
|
||||
// 未知对端(陌生人加好友前先收到消息等场景):异步补拉一次,下次再渲染就有 name/avatar
|
||||
const friend = friendStore.getFriend(peerId)
|
||||
if (!friend) {
|
||||
friendStore.loadFriendInfo(peerId).catch(() => undefined)
|
||||
}
|
||||
|
||||
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage)
|
||||
// 这里拦截下来改走 applyRecall(把原消息翻转为 RECALL 态),不让它作为新消息进列表
|
||||
if (websocketMessage.type === ImMessageType.RECALL) {
|
||||
const recallMessageId = parseRecallMessageId(websocketMessage.content)
|
||||
if (recallMessageId) {
|
||||
conversationStore.applyRecall(
|
||||
ImConversationType.PRIVATE,
|
||||
peerId,
|
||||
recallMessageId,
|
||||
friend?.nickname || '',
|
||||
selfSend
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 后端 DTO → 前端 Message:sendTime 转毫秒;selfSend / senderNickName 是前端补的
|
||||
const message: Message = {
|
||||
id: websocketMessage.id,
|
||||
clientMessageId: websocketMessage.clientMessageId,
|
||||
type: websocketMessage.type,
|
||||
content: websocketMessage.content,
|
||||
status: websocketMessage.status,
|
||||
sendTime: new Date(websocketMessage.sendTime).getTime(),
|
||||
senderId: websocketMessage.senderId,
|
||||
senderNickName: friend?.nickname || '',
|
||||
targetId: websocketMessage.receiverId,
|
||||
selfSend
|
||||
}
|
||||
conversationStore.insertMessage(
|
||||
{
|
||||
type: ImConversationType.PRIVATE,
|
||||
targetId: peerId,
|
||||
name: friend?.nickname || String(peerId),
|
||||
avatar: friend?.avatar || ''
|
||||
},
|
||||
message
|
||||
)
|
||||
|
||||
// 5. 仅对方消息才走「自动已读 / 提示音」分支:自己发的不会触发
|
||||
if (!selfSend) {
|
||||
const conversation = conversationStore.getConversation(ImConversationType.PRIVATE, peerId)
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type === ImConversationType.PRIVATE &&
|
||||
conversationStore.activeConversation?.targetId === peerId
|
||||
if (isActive) {
|
||||
// 聊天窗口打开 = 实际看到了:本端清未读 + 上报后端,让对方 UI 立刻切到"已读"
|
||||
conversationStore.markActiveAsRead()
|
||||
apiReadPrivateMessages(peerId).catch((e) => {
|
||||
console.warn('[IM WS] 自动已读上报失败', e)
|
||||
})
|
||||
} else if (!conversation?.muted) {
|
||||
// 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTip)
|
||||
playAudioTip()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** 私聊 READ 事件:自己的其它终端在对方会话里标为已读,本端同步清零未读 */
|
||||
handlePrivateRead(websocketMessage: ImPrivateMessageDTO) {
|
||||
const conversationStore = useConversationStore()
|
||||
const conversation = conversationStore.getConversation(
|
||||
ImConversationType.PRIVATE,
|
||||
websocketMessage.receiverId
|
||||
)
|
||||
if (conversation) {
|
||||
conversation.unreadCount = 0
|
||||
}
|
||||
conversationStore.saveToStorage()
|
||||
},
|
||||
|
||||
/** 私聊 RECEIPT 事件:对方读了我的消息,把和对方会话里自己发的消息标为已读 */
|
||||
handlePrivateReceipt(websocketMessage: ImPrivateMessageDTO) {
|
||||
const conversationStore = useConversationStore()
|
||||
conversationStore.applyReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: websocketMessage.senderId,
|
||||
markPrivateRead: true
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 群聊普通消息入库 + 自动已读(结构与 handlePrivateMessage 对称,差异点:senderNickName 优先用群备注)
|
||||
*
|
||||
* 流程:
|
||||
* 1. 离线加载期缓冲
|
||||
* 2. 拉群详情 + 解析 senderNickName(群内备注优先)
|
||||
* 3. 撤回 TIP 短路
|
||||
* 4. 构造 Message + at 字段,插入到对应群聊会话
|
||||
* 5. 当前会话激活时自动上报已读(带 lastMessageId);否则非免打扰响提示音
|
||||
*/
|
||||
handleGroupMessage(websocketMessage: ImGroupMessageDTO) {
|
||||
const conversationStore = useConversationStore()
|
||||
// 1. 离线加载期缓冲(与私聊对称)
|
||||
if (conversationStore.loading) {
|
||||
this.messageBuffer.push({ kind: 'group', payload: websocketMessage })
|
||||
return
|
||||
}
|
||||
const userStore = useUserStore()
|
||||
const groupStore = useGroupStore()
|
||||
const currentUserId = Number(userStore.getUser?.id) || 0
|
||||
const selfSend = websocketMessage.senderId === currentUserId
|
||||
|
||||
// 2. 未知群时自动拉群详情 + 成员(被拉入群但还没收到 GROUP_CREATE 时的兜底)
|
||||
const group = groupStore.getGroup(websocketMessage.groupId)
|
||||
if (!group) {
|
||||
groupStore.loadGroupInfo(websocketMessage.groupId).catch(() => undefined)
|
||||
}
|
||||
// senderNickName 取值优先级:群内自定义显示名 > 用户昵称 > 空(群里通常用前者,符合微信式体验)
|
||||
const senderMember = group?.members?.find((m) => m.userId === websocketMessage.senderId)
|
||||
const senderNickName = senderMember?.displayUserName || senderMember?.nickname || ''
|
||||
|
||||
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`
|
||||
// 这里拦截下来改走 applyRecall(把原消息翻转为 RECALL 态)
|
||||
if (websocketMessage.type === ImMessageType.RECALL) {
|
||||
const recallMessageId = parseRecallMessageId(websocketMessage.content)
|
||||
if (recallMessageId) {
|
||||
conversationStore.applyRecall(
|
||||
ImConversationType.GROUP,
|
||||
websocketMessage.groupId,
|
||||
recallMessageId,
|
||||
senderNickName,
|
||||
selfSend
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 后端 DTO → 前端 Message:群消息额外带 atUserIds / receiverUserIds,给 @ 标记和回执用
|
||||
const message: Message = {
|
||||
id: websocketMessage.id,
|
||||
clientMessageId: websocketMessage.clientMessageId,
|
||||
type: websocketMessage.type,
|
||||
content: websocketMessage.content,
|
||||
status: websocketMessage.status,
|
||||
sendTime: new Date(websocketMessage.sendTime).getTime(),
|
||||
senderId: websocketMessage.senderId,
|
||||
senderNickName,
|
||||
targetId: websocketMessage.groupId,
|
||||
selfSend,
|
||||
atUserIds: websocketMessage.atUserIds || [],
|
||||
receiverUserIds: websocketMessage.receiverUserIds || []
|
||||
}
|
||||
conversationStore.insertMessage(
|
||||
{
|
||||
type: ImConversationType.GROUP,
|
||||
targetId: websocketMessage.groupId,
|
||||
name: group?.name || String(websocketMessage.groupId),
|
||||
avatar: group?.avatar || ''
|
||||
},
|
||||
message
|
||||
)
|
||||
|
||||
// 5. 仅对方消息才走「自动已读 / 提示音」(与私聊对称)
|
||||
if (!selfSend) {
|
||||
const conversation = conversationStore.getConversation(
|
||||
ImConversationType.GROUP,
|
||||
websocketMessage.groupId
|
||||
)
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type === ImConversationType.GROUP &&
|
||||
conversationStore.activeConversation?.targetId === websocketMessage.groupId
|
||||
if (isActive) {
|
||||
// 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId)
|
||||
conversationStore.markActiveAsRead()
|
||||
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => {
|
||||
console.warn('[IM WS] 自动已读上报失败', e)
|
||||
})
|
||||
} else if (!conversation?.muted) {
|
||||
playAudioTip()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 群聊已读 / 回执 ====================
|
||||
|
||||
/** 群聊 READ:自己其它终端在某群里标为已读,本端同步清零该群未读 */
|
||||
handleGroupRead(websocketMessage: ImGroupMessageDTO) {
|
||||
const conversationStore = useConversationStore()
|
||||
const conversation = conversationStore.getConversation(
|
||||
ImConversationType.GROUP,
|
||||
websocketMessage.groupId
|
||||
)
|
||||
if (conversation) {
|
||||
conversation.unreadCount = 0
|
||||
}
|
||||
conversationStore.saveToStorage()
|
||||
},
|
||||
|
||||
/** 群聊 RECEIPT:更新某条群消息的 readCount / receiptStatus */
|
||||
handleGroupReceipt(websocketMessage: ImGroupMessageDTO) {
|
||||
const conversationStore = useConversationStore()
|
||||
conversationStore.applyReadReceipt({
|
||||
conversationType: ImConversationType.GROUP,
|
||||
targetId: websocketMessage.groupId,
|
||||
groupMessageId: websocketMessage.id,
|
||||
readCount: websocketMessage.readCount,
|
||||
receiptStatus: websocketMessage.receiptStatus
|
||||
})
|
||||
},
|
||||
|
||||
// ==================== 好友关系事件(承载于私聊通道,按 inner type 分流) ====================
|
||||
|
||||
/** FRIEND_ADD:后端推送给好友双方;本端拉取好友详情并入库,级联刷新私聊会话展示 */
|
||||
handleFriendAdd(websocketMessage: ImPrivateMessageDTO) {
|
||||
const friendStore = useFriendStore()
|
||||
// 后端 DTO 里只带 senderId/receiverId;收到这条时,对端 = 非自己的那一方
|
||||
const userStore = useUserStore()
|
||||
const selfId = Number(userStore.getUser?.id) || 0
|
||||
const friendUserId =
|
||||
websocketMessage.senderId === selfId
|
||||
? websocketMessage.receiverId
|
||||
: websocketMessage.senderId
|
||||
friendStore.loadFriendInfo(friendUserId).catch(() => undefined)
|
||||
},
|
||||
|
||||
/** FRIEND_DELETE:本端标记好友已删 + 级联清理私聊会话 */
|
||||
handleFriendDelete(websocketMessage: ImPrivateMessageDTO) {
|
||||
const friendStore = useFriendStore()
|
||||
const userStore = useUserStore()
|
||||
const selfId = Number(userStore.getUser?.id) || 0
|
||||
const friendUserId =
|
||||
websocketMessage.senderId === selfId
|
||||
? websocketMessage.receiverId
|
||||
: websocketMessage.senderId
|
||||
friendStore.removeFriend(friendUserId)
|
||||
},
|
||||
|
||||
/** FRIEND_UPDATE:多端同步好友属性变更(当前主要是免打扰);重新拉取好友详情即可 */
|
||||
handleFriendUpdate(websocketMessage: ImPrivateMessageDTO) {
|
||||
const friendStore = useFriendStore()
|
||||
const userStore = useUserStore()
|
||||
const selfId = Number(userStore.getUser?.id) || 0
|
||||
const friendUserId =
|
||||
websocketMessage.senderId === selfId
|
||||
? websocketMessage.receiverId
|
||||
: websocketMessage.senderId
|
||||
friendStore.loadFriendInfo(friendUserId).catch(() => undefined)
|
||||
},
|
||||
|
||||
// ==================== 群关系事件(承载于群聊通道,按 inner type 分流) ====================
|
||||
|
||||
/** GROUP_CREATE:本端入群(建群 / 被拉入);拉取群详情入库 */
|
||||
handleGroupCreate(websocketMessage: ImGroupMessageDTO) {
|
||||
const groupStore = useGroupStore()
|
||||
groupStore.loadGroupInfo(websocketMessage.groupId).catch(() => undefined)
|
||||
},
|
||||
|
||||
/** GROUP_UPDATE:群信息变更,重新拉一次群详情 */
|
||||
handleGroupUpdate(websocketMessage: ImGroupMessageDTO) {
|
||||
const groupStore = useGroupStore()
|
||||
groupStore.loadGroupInfo(websocketMessage.groupId).catch(() => undefined)
|
||||
},
|
||||
|
||||
/** GROUP_DELETE:群解散 / 自己退群 / 被踢出;本端清除群 + 级联清理群聊会话 */
|
||||
handleGroupDelete(websocketMessage: ImGroupMessageDTO) {
|
||||
const groupStore = useGroupStore()
|
||||
groupStore.removeGroup(websocketMessage.groupId)
|
||||
},
|
||||
|
||||
/** GROUP_MEMBER_UPDATE:多端同步自己在某群的成员属性变更(当前主要是免打扰);重新拉群详情 */
|
||||
handleGroupMemberUpdate(websocketMessage: ImGroupMessageDTO) {
|
||||
const groupStore = useGroupStore()
|
||||
groupStore.loadGroupInfo(websocketMessage.groupId).catch(() => undefined)
|
||||
},
|
||||
|
||||
// ==================== 心跳 / 重连 ====================
|
||||
|
||||
/** 心跳包:纯文本 'ping',对应服务端 'pong'(后端这层用纯字符串约定,避免 JSON 解析开销) */
|
||||
sendHeartBeat() {
|
||||
if (this.socket && this.isConnected) {
|
||||
this.socket.send('ping')
|
||||
}
|
||||
},
|
||||
|
||||
/** 主动断开(切换用户 / 退出登录时用):关 socket + 停心跳 + 取消待重连 */
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.close()
|
||||
this.socket = null
|
||||
}
|
||||
this.stopHeartbeat()
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
/** 自动重连,3 秒后再试(onclose / onerror 都会进来,靠 reconnectTimer 自身防重) */
|
||||
reconnect() {
|
||||
this.stopHeartbeat()
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
console.log('[IM WS] reconnecting...')
|
||||
this.connect()
|
||||
}, 3000)
|
||||
},
|
||||
|
||||
/** 心跳 5 秒一次,保活 + 探活(链路断了 onclose 会触发,由 reconnect 兜底) */
|
||||
startHeartbeat() {
|
||||
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.socket && this.isConnected) {
|
||||
this.sendHeartBeat()
|
||||
}
|
||||
}, 5000)
|
||||
},
|
||||
|
||||
stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const useImWebSocketStoreWithOut = () => {
|
||||
return useImWebSocketStore(store)
|
||||
}
|
||||
|
|
@ -1,3 +1,39 @@
|
|||
// ==================== WebSocket 帧 / 事件 ====================
|
||||
|
||||
// 后端 WebSocket 统一帧结构:{ type, content }
|
||||
export interface WebSocketFrame {
|
||||
type: string // 帧类型,对齐 ImWebSocketMessageType
|
||||
content: string // 帧内容(JSON 字符串)
|
||||
}
|
||||
|
||||
// 私聊消息 DTO(对齐后端 ImPrivateMessageDTO)
|
||||
export interface ImPrivateMessageDTO {
|
||||
id: number // 消息编号
|
||||
clientMessageId: string // 客户端消息编号
|
||||
senderId: number // 发送人编号
|
||||
receiverId: number // 接收人编号
|
||||
type: number // 消息类型
|
||||
content: string // 消息内容
|
||||
status: number // 消息状态
|
||||
sendTime: string // 发送时间
|
||||
}
|
||||
|
||||
// 群聊消息 DTO(对齐后端 ImGroupMessageDTO)
|
||||
export interface ImGroupMessageDTO {
|
||||
id: number // 消息编号
|
||||
clientMessageId: string // 客户端消息编号
|
||||
senderId: number // 发送人编号
|
||||
groupId: number // 群编号
|
||||
type: number // 消息类型
|
||||
content: string // 消息内容
|
||||
status: number // 消息状态
|
||||
sendTime: string // 发送时间
|
||||
atUserIds?: number[] // 群 @ 目标用户列表
|
||||
receiverUserIds?: number[] // 群定向接收用户列表
|
||||
readCount?: number // 群回执已读人数(type = RECEIPT 时使用)
|
||||
receiptStatus?: number // 群回执状态(type = RECEIPT 时使用)
|
||||
}
|
||||
|
||||
// ==================== 本地会话 / 消息结构 ====================
|
||||
|
||||
// 会话数据结构(前端自有结构,后端无对应实体)
|
||||
|
|
@ -7,8 +43,8 @@ export interface Conversation {
|
|||
type: number // 会话类型,对齐 ImConversationType
|
||||
|
||||
// ========== 展示字段 ==========
|
||||
showName: string // 展示名称
|
||||
showImage: string // 头像
|
||||
name: string // 展示名称(私聊=好友昵称;群聊=群名)
|
||||
avatar: string // 头像
|
||||
lastContent: string // 会话列表展示的最后一条消息摘要
|
||||
lastSendTime: number // 最后一条消息时间,用于排序
|
||||
unreadCount: number // 未读数
|
||||
|
|
@ -53,38 +89,50 @@ export interface ConversationsData {
|
|||
conversations: Conversation[] // 会话列表
|
||||
}
|
||||
|
||||
// ==================== WebSocket 帧 / 事件 ====================
|
||||
// ==================== 群 / 群成员 ====================
|
||||
|
||||
// 后端 WebSocket 统一帧结构:{ type, content }
|
||||
export interface WebSocketFrame {
|
||||
type: string // 帧类型,对齐 ImWebSocketMessageType
|
||||
content: string // 帧内容(JSON 字符串)
|
||||
// 群实体(前端内部结构)
|
||||
export interface Group {
|
||||
// ========== 后端字段(对齐 ImGroupRespVO) ==========
|
||||
id: number // 群编号
|
||||
name: string // 群名称
|
||||
avatar?: string // 群头像
|
||||
notice?: string // 群公告
|
||||
ownerUserId?: number // 群主用户编号
|
||||
|
||||
// ========== 前端扩展字段 ==========
|
||||
muted?: boolean // 是否免打扰(来自当前用户的 ImGroupMemberRespVO.muted)
|
||||
members?: GroupMember[] // 群成员缓存(按需懒加载)
|
||||
memberCount?: number // 成员总数
|
||||
}
|
||||
|
||||
// 私聊消息 DTO(对齐后端 ImPrivateMessageDTO)
|
||||
export interface ImPrivateMessageDTO {
|
||||
id: number // 消息编号
|
||||
clientMessageId: string // 客户端消息编号
|
||||
senderId: number // 发送人编号
|
||||
receiverId: number // 接收人编号
|
||||
type: number // 消息类型
|
||||
content: string // 消息内容
|
||||
status: number // 消息状态
|
||||
sendTime: string // 发送时间
|
||||
}
|
||||
|
||||
// 群聊消息 DTO(对齐后端 ImGroupMessageDTO)
|
||||
export interface ImGroupMessageDTO {
|
||||
id: number // 消息编号
|
||||
clientMessageId: string // 客户端消息编号
|
||||
senderId: number // 发送人编号
|
||||
// 群成员实体(前端内部结构)
|
||||
export interface GroupMember {
|
||||
// ========== 后端字段(对齐 ImGroupMemberRespVO) ==========
|
||||
id?: number // 群成员关系记录编号
|
||||
groupId: number // 群编号
|
||||
type: number // 消息类型
|
||||
content: string // 消息内容
|
||||
status: number // 消息状态
|
||||
sendTime: string // 发送时间
|
||||
atUserIds?: number[] // 群 @ 目标用户列表
|
||||
receiverUserIds?: number[] // 群定向接收用户列表
|
||||
readCount?: number // 群回执已读人数(type = RECEIPT 时使用)
|
||||
receiptStatus?: number // 群回执状态(type = RECEIPT 时使用)
|
||||
userId: number // 用户编号
|
||||
avatar?: string // 头像
|
||||
nickname: string // 用户昵称
|
||||
displayUserName?: string // 组内显示名(不与 nickname 合并,由消费方按需取舍)
|
||||
displayGroupName?: string // 群显示备注(当前用户对该群的自定义名)
|
||||
status?: number // 在群 / 退群状态,对齐 CommonStatusEnum
|
||||
|
||||
// ========== 前端扩展字段 ==========
|
||||
isOwner?: boolean // 是否群主(前端从 Group.ownerUserId 计算)
|
||||
}
|
||||
|
||||
// ==================== 好友 ====================
|
||||
|
||||
// 好友实体(前端内部结构)
|
||||
export interface Friend {
|
||||
// ========== 后端字段(对齐 ImFriendRespVO) ==========
|
||||
id?: number // 好友关系记录编号(本地乐观新增时可能暂缺)
|
||||
friendUserId: number // 好友用户编号(与 Conversation.targetId 对齐)
|
||||
nickname: string // 好友昵称
|
||||
avatar?: string // 好友头像
|
||||
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
|
||||
status?: number // 好友状态,对齐 CommonStatusEnum(DISABLE = 已删除/墓碑)
|
||||
addTime?: string // 添加好友时间
|
||||
deleteTime?: string // 删除好友时间
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const parseMessage = <T>(content: string): T | null => {
|
|||
/** 序列化消息 payload 为 content JSON 字符串;与 parseMessage 对称 */
|
||||
export const serializeMessage = <T>(payload: T): string => JSON.stringify(payload)
|
||||
|
||||
// ==================== 撤回提示文案 ====================
|
||||
// ==================== 撤回 ====================
|
||||
|
||||
/**
|
||||
* 生成本地「撤回提示消息」的展示内容
|
||||
|
|
@ -89,6 +89,19 @@ export const buildRecallTip = (senderName: string, selfSend: boolean): string =>
|
|||
return selfSend ? '你撤回了一条消息' : `${senderName || '对方'} 撤回了一条消息`
|
||||
}
|
||||
|
||||
/**
|
||||
* 从后端下发的撤回 TIP_TEXT content 中解析出被撤回的原消息 id
|
||||
* content 形如 `{"messageId": 123}`,若不含 messageId 则返回 0(表示这条不是撤回 tip)
|
||||
*/
|
||||
export const parseRecallMessageId = (content: string): number => {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
return parsed?.messageId != null ? Number(parsed.messageId) : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 新消息提示音 ====================
|
||||
|
||||
import tipAudioUrl from '@/assets/audio/im/message-tip.mp3'
|
||||
|
|
|
|||
Loading…
Reference in New Issue