diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts index d2e1a6237..8886f34a7 100644 --- a/src/views/im/home/store/conversationStore.ts +++ b/src/views/im/home/store/conversationStore.ts @@ -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 ) { - 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) { diff --git a/src/views/im/home/store/friendStore.ts b/src/views/im/home/store/friendStore.ts new file mode 100644 index 000000000..1868f317b --- /dev/null +++ b/src/views/im/home/store/friendStore.ts @@ -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) { + 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) diff --git a/src/views/im/home/store/groupStore.ts b/src/views/im/home/store/groupStore.ts new file mode 100644 index 000000000..b8a73aa1d --- /dev/null +++ b/src/views/im/home/store/groupStore.ts @@ -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 { + // 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) diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts new file mode 100644 index 000000000..8a9c69dea --- /dev/null +++ b/src/views/im/home/store/websocketStore.ts @@ -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 | null, + heartbeatTimer: null as ReturnType | 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 | null { + if (!raw) { + return null + } + if (typeof raw === 'object') { + return raw as Record + } + 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) +} diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts index a774f1f6f..e5b34bd09 100644 --- a/src/views/im/home/types/index.ts +++ b/src/views/im/home/types/index.ts @@ -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 // 删除好友时间 } diff --git a/src/views/im/utils/message.ts b/src/views/im/utils/message.ts index 6b6136361..1f74177e0 100644 --- a/src/views/im/utils/message.ts +++ b/src/views/im/utils/message.ts @@ -79,7 +79,7 @@ export const parseMessage = (content: string): T | null => { /** 序列化消息 payload 为 content JSON 字符串;与 parseMessage 对称 */ export const serializeMessage = (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'