diff --git a/src/api/im/friend/index.ts b/src/api/im/friend/index.ts index bd39c3c1c..f6f1afa90 100644 --- a/src/api/im/friend/index.ts +++ b/src/api/im/friend/index.ts @@ -7,6 +7,9 @@ export interface ImFriendRespVO { muted?: boolean // 是否免打扰 displayName?: string // 好友展示备注(仅自己可见) displayNamePinyin?: string // 备注的拼音(小写无空格,前端按首字母分桶 / 拼音搜索) + addSource?: number // 添加来源;参见 ImFriendAddSourceEnum + pinned?: boolean // 是否置顶联系人 + blocked?: boolean // 是否拉黑 status?: number // 好友状态(0=正常,1=已删除) addTime?: string // 添加好友时间 deleteTime?: string // 删除好友时间 @@ -21,6 +24,7 @@ export interface ImFriendUpdateReqVO { friendUserId: number // 好友的用户编号 muted?: boolean // 是否免打扰 displayName?: string // 好友展示备注 + pinned?: boolean // 是否置顶联系人 } // 获得当前登录用户的好友列表 @@ -33,17 +37,13 @@ export const getFriend = (friendUserId: number | string) => { return request.get({ url: '/im/friend/get', params: { friendUserId } }) } -// 添加好友(双向建立关系) -export const addFriend = (friendUserId: number | string) => { - return request.post({ url: '/im/friend/add', params: { friendUserId } }) -} - -// 删除好友(双向软删除) +// 删除好友(单向软删除) export const deleteFriend = (friendUserId: number | string) => { return request.delete({ url: '/im/friend/delete', params: { friendUserId } }) } -// 更新好友信息 +// 更新好友信息(备注 / 免打扰 / 联系人置顶) export const updateFriend = (data: ImFriendUpdateReqVO) => { return request.put({ url: '/im/friend/update', data }) } + diff --git a/src/api/im/friend/request/index.ts b/src/api/im/friend/request/index.ts new file mode 100644 index 000000000..73cb801dd --- /dev/null +++ b/src/api/im/friend/request/index.ts @@ -0,0 +1,51 @@ +import request from '@/config/axios' + +// TODO DONE @AI:路径迁移到 api/im/friend/request/index.ts,与 api/im/group/member 这种嵌套结构对齐 +// IM 好友申请 Response VO +export interface ImFriendRequestRespVO { + id: number // 申请编号 + fromUserId: number // 发起方用户编号 + toUserId: number // 接收方用户编号 + handleResult: number // 处理结果;0=未处理;1=同意;2=拒绝 + applyContent?: string // 申请理由 + handleContent?: string // 处理理由(接收方拒绝时可选填) + addSource?: number // 添加来源;参见 ImFriendAddSourceEnum + handleTime?: string // 处理时间 + createTime: string // 申请创建时间 + // 聚合字段(自 AdminUser) + fromNickname?: string // 发起方昵称 + fromAvatar?: string // 发起方头像 + toNickname?: string // 接收方昵称 + toAvatar?: string // 接收方头像 +} + +// IM 好友申请发起 Request VO +export interface ImFriendRequestApplyReqVO { + toUserId: number // 接收方用户编号 + applyContent?: string // 申请理由 + displayName?: string // 对接收方的备注(仅自己可见) + addSource?: number // 添加来源 +} + +// 发起好友申请 +export const applyFriendRequest = (data: ImFriendRequestApplyReqVO) => { + return request.post({ url: '/im/friend-request/apply', data }) +} + +// 同意好友申请 +export const agreeFriendRequest = (id: number | string) => { + return request.put({ url: '/im/friend-request/agree', params: { id } }) +} + +// 拒绝好友申请 +export const refuseFriendRequest = (id: number | string, handleContent?: string) => { + return request.put({ + url: '/im/friend-request/refuse', + params: { id, handleContent } + }) +} + +// 查询「我相关」的好友申请列表(含我发起的、别人加我的) +export const getMyFriendRequestList = () => { + return request.get({ url: '/im/friend-request/list' }) +} diff --git a/src/views/im/home/store/friendStore.ts b/src/views/im/home/store/friendStore.ts index b243c73e8..a4c8132a6 100644 --- a/src/views/im/home/store/friendStore.ts +++ b/src/views/im/home/store/friendStore.ts @@ -5,30 +5,61 @@ 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 { + applyFriendRequest as apiApplyFriendRequest, + agreeFriendRequest as apiAgreeFriendRequest, + refuseFriendRequest as apiRefuseFriendRequest, + getMyFriendRequestList as apiGetMyFriendRequestList, + type ImFriendRequestApplyReqVO, + type ImFriendRequestRespVO +} from '@/api/im/friend/request' import { useConversationStore } from './conversationStore' import { ImConversationType } from '../../utils/constants' import { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage' import { getFriendDisplayName } from '../../utils/user' -import type { Friend } from '../types' +import type { Friend, FriendRequest } from '../types' + +/** 好友申请处理结果(对齐后端 ImFriendRequestHandleResultEnum) */ +const FriendRequestHandleResult = { + UNHANDLED: 0, + AGREED: 1, + REFUSED: 2 +} as const + +/** 好友通知 payload(对齐后端 BaseFriendNotification + 子类裁减后的字段) */ +export interface FriendNotificationPayload { + operatorUserId: number + friendUserId: number + // FRIEND_APPLICATION 系列:申请记录的核心字段(避免 payload 携带完整 DO) + requestId?: number + applyContent?: string + handleContent?: string + addSource?: number + // FRIEND_UPDATE:单边属性变更 + displayName?: string + muted?: boolean + pinned?: boolean +} /** * IM 好友 Store * * 负责: - * - 拉取 / 缓存当前登录用户的好友列表 - * - 加好友 / 删好友(走后端 API + 本地乐观同步) - * - 被 ConversationItem / FriendPage / MessageInput 等多处消费 + * - 拉取 / 缓存当前登录用户的好友列表 + 申请列表 + * - 申请-审批流程(apply / agree / refuse)+ 备注 / 免打扰 / 联系人置顶 / 拉黑 + * - 接收 WebSocket 1201-1210 段位通知,按事件分发到 friendStore 内部各 dispatcher */ export const useFriendStore = defineStore('imFriendStore', { state: () => ({ friends: [] as Friend[], // 仅 fetchFriends 成功后置位;loadFriends(IDB)不置位,否则后台 SWR 刷新会被缓存命中跳过 - loaded: false + loaded: false, + /** 我相关的好友申请列表(含我发起的 + 别人加我的;后端按 id 倒序,前端不再分页) */ + friendRequests: [] as FriendRequest[] }), getters: { @@ -48,17 +79,26 @@ export const useFriendStore = defineStore('imFriendStore', { const entry = this.getFriend(friendUserId) return !!entry && entry.status !== CommonStatusEnum.DISABLE } + }, + /** 我的黑名单(blocked=true 且 ENABLE) */ + getBlockedFriends: (state): Friend[] => { + return state.friends.filter( + (f) => f.status !== CommonStatusEnum.DISABLE && f.blocked === true + ) + }, + /** 未处理申请数(接收方=我)—— 实时派生,「新的朋友」红点用 */ + getUnhandledRequestCount: (state): number => { + const me = Number(getCurrentUserId() || 0) + return state.friendRequests.filter( + (r) => r.handleResult === FriendRequestHandleResult.UNHANDLED && r.toUserId === me + ).length } }, actions: { // ==================== 本地缓存 ==================== - /** - * 从 IDB 恢复好友列表 - * - * @return 返回是否命中缓存 - */ + /** 从 IDB 恢复好友列表 */ async loadFriends(): Promise { const userId = getCurrentUserId() if (!userId) { @@ -121,30 +161,96 @@ export const useFriendStore = defineStore('imFriendStore', { } }, - /** 添加好友:后端双向建立关系后,本地占位插入(服务端返回后可 fetchFriends 刷新) */ - 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 - }) + // ==================== 申请-审批 ==================== + + /** 发起好友申请:成功后等待对方同意(不直接落地为好友) */ + async applyFriend(reqVO: ImFriendRequestApplyReqVO): Promise { + return await apiApplyFriendRequest(reqVO) + }, + + /** 同意一条好友申请;后端会双向落库 + 推 FRIEND_ADD,本端等通知到达再 upsertFriend */ + async agreeFriendRequest(requestId: number) { + await apiAgreeFriendRequest(requestId) + const request = this.findFriendRequest(requestId) + if (request) { + request.handleResult = FriendRequestHandleResult.AGREED + request.handleTime = Date.now() } else { - await this.loadFriendInfo(friendUserId) + // 列表过期场景兜底重拉 + await this.fetchFriendRequests() } }, - /** 删除好友(软删,保留记录但置 DISABLE;同时级联清理本地私聊会话) */ + /** 拒绝一条好友申请 */ + async refuseFriendRequest(requestId: number, handleContent?: string) { + await apiRefuseFriendRequest(requestId, handleContent) + const request = this.findFriendRequest(requestId) + if (request) { + request.handleResult = FriendRequestHandleResult.REFUSED + request.handleContent = handleContent + request.handleTime = Date.now() + } else { + await this.fetchFriendRequests() + } + }, + + /** 拉取「我相关」的好友申请列表(页面打开时 / 收到 FRIEND_APPLICATION 时刷新) */ + async fetchFriendRequests() { + const list = await apiGetMyFriendRequestList() + this.friendRequests = (list || []).map(convertFriendRequest) + }, + + /** 按 id 查申请记录 */ + findFriendRequest(requestId: number): FriendRequest | undefined { + return this.friendRequests.find((r) => r.id === requestId) + }, + + // ==================== 好友关系操作 ==================== + + /** 删除好友(单向软删,本端置 DISABLE;级联清理本地私聊会话) */ async deleteFriend(friendUserId: number) { await apiDeleteFriend(friendUserId) this.removeFriend(friendUserId) }, + /** 切换免打扰 */ + async setMuted(friendUserId: number, muted: boolean) { + await apiUpdateFriend({ friendUserId, muted }) + const friend = this.getFriend(friendUserId) + if (friend) { + friend.muted = muted + this.saveFriends() + } + }, + + /** 切换联系人置顶 */ + async setPinned(friendUserId: number, pinned: boolean) { + await apiUpdateFriend({ friendUserId, pinned }) + const friend = this.getFriend(friendUserId) + if (friend) { + friend.pinned = pinned + this.saveFriends() + } + }, + + /** 修改好友展示备注(仅自己可见) */ + async setDisplayName(friendUserId: number, displayName: string) { + const value = displayName.trim() + // 后端 displayName 语义:null/undefined = 不改,"" = 清空,所以这里直接传 value(可能是空串) + await apiUpdateFriend({ friendUserId, displayName: value }) + const friend = this.getFriend(friendUserId) + if (friend) { + friend.displayName = value + const conversationStore = useConversationStore() + conversationStore.updateConversation(ImConversationType.PRIVATE, friendUserId, { + name: getFriendDisplayName(friend) + }) + this.saveFriends() + } + }, + /** 本地合并 / 新增某个好友(WebSocket 事件 & 手动刷新都用) */ upsertFriend(friend: Friend) { - // 按 friendUserId 查已有记录下标:>=0 命中则覆盖合并,<0 则追加 const index = this.friends.findIndex((f) => f.friendUserId === friend.friendUserId) if (index >= 0) { this.friends[index] = { @@ -158,7 +264,6 @@ export const useFriendStore = defineStore('imFriendStore', { status: friend.status ?? CommonStatusEnum.ENABLE }) } - // 同步对应私聊会话的展示 const conversationStore = useConversationStore() const merged = this.getFriend(friend.friendUserId) conversationStore.updateConversation(ImConversationType.PRIVATE, friend.friendUserId, { @@ -169,13 +274,13 @@ export const useFriendStore = defineStore('imFriendStore', { this.saveFriends() }, - /** 本地标记删除(WebSocket FRIEND_DEL 事件触发;同时级联清私聊会话) */ + /** 本地标记删除(WebSocket FRIEND_DELETE 事件触发;同时级联清私聊会话) */ removeFriend(friendUserId: number) { - // 软删:保留记录但置为 DISABLE,避免后续误判"陌生人" const friend = this.getFriend(friendUserId) if (friend) { friend.status = CommonStatusEnum.DISABLE friend.deleteTime = Date.now() + friend.blocked = false } // 级联清理:把对应的私聊会话也软删,避免会话列表里留着已删好友 const conversationStore = useConversationStore() @@ -183,39 +288,105 @@ export const useFriendStore = defineStore('imFriendStore', { this.saveFriends() }, - /** 切换免打扰 */ - async setMuted(friendUserId: number, muted: boolean) { - await apiUpdateFriend({ friendUserId, muted }) - const friend = this.getFriend(friendUserId) + // ==================== WebSocket 事件 dispatcher(1201-1210 段) ==================== + + /** FRIEND_APPLICATION(1203):收到新申请;payload 已裁减为核心字段,本地拉一次列表补齐 fromUser 等聚合字段 */ + applyFriendRequestNotification(_payload: FriendNotificationPayload) { + this.fetchFriendRequests().catch(() => undefined) + }, + + /** FRIEND_REQUEST_APPROVED(1201):我的申请被同意;按 requestId 更新状态(FRIEND_ADD 会另外推) */ + applyFriendRequestApprovedNotification(payload: FriendNotificationPayload) { + const request = payload.requestId ? this.findFriendRequest(payload.requestId) : undefined + if (request) { + request.handleResult = FriendRequestHandleResult.AGREED + request.handleTime = Date.now() + } else { + this.fetchFriendRequests().catch(() => undefined) + } + }, + + /** FRIEND_REQUEST_REJECTED(1202):我的申请被拒绝;按 requestId 更新状态 */ + applyFriendRequestRejectedNotification(payload: FriendNotificationPayload) { + const request = payload.requestId ? this.findFriendRequest(payload.requestId) : undefined + if (request) { + request.handleResult = FriendRequestHandleResult.REFUSED + request.handleContent = payload.handleContent + request.handleTime = Date.now() + } else { + this.fetchFriendRequests().catch(() => undefined) + } + }, + + /** FRIEND_ADD(1204):新增好友;本端拉取好友详情并入库 */ + applyFriendAddNotification(payload: FriendNotificationPayload) { + if (payload.friendUserId) { + this.loadFriendInfo(payload.friendUserId).catch(() => undefined) + } + }, + + /** FRIEND_DELETE(1205):好友被删除;本端清理 + 级联会话 */ + applyFriendDeleteNotification(payload: FriendNotificationPayload) { + if (payload.friendUserId) { + this.removeFriend(payload.friendUserId) + } + }, + + /** FRIEND_BLOCK(1207):拉黑;多端同步 */ + applyFriendBlockNotification(payload: FriendNotificationPayload) { + const friend = this.getFriend(payload.friendUserId) if (friend) { - friend.muted = muted + friend.blocked = true + this.saveFriends() + } + }, + + /** FRIEND_UNBLOCK(1208):移出黑名单;多端同步 */ + applyFriendUnblockNotification(payload: FriendNotificationPayload) { + const friend = this.getFriend(payload.friendUserId) + if (friend) { + friend.blocked = false this.saveFriends() } }, /** - * 修改好友展示备注(仅自己可见) - * - * 走后端 /im/friend/update 接口;保存成功后再同步本地 friend + 会话列表 name,失败直接抛给上层让 UI 决定回滚 / 提示 + * FRIEND_INFO_UPDATED(1209):好友资料变更(昵称 / 头像);重拉详情 + * TODO @AI:后端暂未实现 1209 推送;待 system 模块改昵称 / 头像时回调触发,本 dispatcher 已就绪 */ - async setDisplayName(friendUserId: number, displayName: string) { - const value = displayName.trim() - // 后端的 displayName 语义:null/undefined = 不改,"" = 清空,所以这里直接传 value(可能是空串) - await apiUpdateFriend({ friendUserId, displayName: value }) - const friend = this.getFriend(friendUserId) - if (friend) { - friend.displayName = value - const conversationStore = useConversationStore() - conversationStore.updateConversation(ImConversationType.PRIVATE, friendUserId, { - name: getFriendDisplayName(friend) - }) - this.saveFriends() + applyFriendInfoUpdatedNotification(payload: FriendNotificationPayload) { + if (payload.friendUserId) { + this.loadFriendInfo(payload.friendUserId).catch(() => undefined) } }, + /** FRIEND_UPDATE(1210):批量更新(备注 / 免打扰 / 联系人置顶);多端同步 */ + applyFriendUpdateNotification(payload: FriendNotificationPayload) { + const friend = this.getFriend(payload.friendUserId) + if (!friend) { + return + } + if (payload.displayName != null) { + friend.displayName = payload.displayName + } + if (payload.muted != null) { + friend.muted = payload.muted + } + if (payload.pinned != null) { + friend.pinned = payload.pinned + } + const conversationStore = useConversationStore() + conversationStore.updateConversation(ImConversationType.PRIVATE, payload.friendUserId, { + name: getFriendDisplayName(friend), + muted: friend.muted + }) + this.saveFriends() + }, + /** 切账号时仅清 in-memory,IDB 按 userId 分桶天然隔离,回切秒开 */ clear() { this.friends = [] + this.friendRequests = [] this.loaded = false } } @@ -231,16 +402,36 @@ function convertFriend(vo: ImFriendRespVO): Friend { muted: !!vo.muted, displayName: vo.displayName || '', displayNamePinyin: vo.displayNamePinyin, + addSource: vo.addSource, + pinned: !!vo.pinned, + blocked: !!vo.blocked, status: vo.status, addTime: vo.addTime ? new Date(vo.addTime).getTime() : undefined, deleteTime: vo.deleteTime ? new Date(vo.deleteTime).getTime() : undefined } } +function convertFriendRequest(vo: ImFriendRequestRespVO): FriendRequest { + return { + id: vo.id, + fromUserId: vo.fromUserId, + toUserId: vo.toUserId, + handleResult: vo.handleResult, + applyContent: vo.applyContent, + handleContent: vo.handleContent, + addSource: vo.addSource, + handleTime: vo.handleTime ? new Date(vo.handleTime).getTime() : undefined, + createTime: vo.createTime ? new Date(vo.createTime).getTime() : 0, + fromNickname: vo.fromNickname, + fromAvatar: vo.fromAvatar, + toNickname: vo.toNickname, + toAvatar: vo.toAvatar + } +} + export const useFriendStoreWithOut = () => useFriendStore(store) // dev: 让 Pinia 的 actions / state 改动支持 HMR,避免每次改 store 都得硬刷 -// 否则 Vite 把新模块推下来后,老 store 实例的 action 闭包仍指向旧函数体 if (import.meta.hot) { import.meta.hot.accept(acceptHMRUpdate(useFriendStore, import.meta.hot)) } diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index 3e59359a1..7c00ef495 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -3,10 +3,15 @@ import { store } from '@/store' import { getRefreshToken } from '@/utils/auth' import { useUserStore } from '@/store/modules/user' -import { ImWebSocketMessageType, ImMessageType, ImConversationType } from '../../utils/constants' +import { + ImWebSocketMessageType, + ImMessageType, + ImConversationType, + isFriendNotification +} from '../../utils/constants' import { playAudioTip } from '../../utils/message' import { useConversationStore } from './conversationStore' -import { useFriendStore } from './friendStore' +import { useFriendStore, type FriendNotificationPayload } from './friendStore' import { getFriendDisplayName } from '../../utils/user' import { useGroupStore } from './groupStore' import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/message/private' @@ -202,30 +207,30 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { // ==================== 普通消息 ==================== /** - * 私聊统一帧分发:按 payload.type(ImMessageType)分到已读 / 回执 / 好友变更 / 普通消息 + * 私聊统一帧分发:按 payload.type(ImMessageType)分到已读 / 回执 / 好友通知 / 普通消息 * - * 对应后端 ImPrivateMessageDTO 的 ofRead / ofReceipt / ofFriendAdd / ofFriendDelete / ofFriendUpdate / ofSend + * 对应后端 ImPrivateMessageDTO 的 ofRead / ofReceipt / ofFriendNotification / 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) + try { + switch (websocketMessage.type) { + case ImMessageType.READ: + this.handlePrivateRead(websocketMessage) + break + case ImMessageType.RECEIPT: + this.handlePrivateReceipt(websocketMessage) + break + default: + if (isFriendNotification(websocketMessage.type)) { + this.handleFriendNotification(websocketMessage) + } else { + // TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT 等普通消息 + this.handlePrivateMessage(websocketMessage) + } + } + } catch (e) { + // 单条帧的处理异常不应阻断后续帧;打印完整 websocketMessage 便于排查 + console.warn('[IM WS] dispatchPrivateFrame 处理失败', websocketMessage, e) } }, @@ -235,19 +240,24 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { * 1530 GROUP_MEMBER_SETTING_UPDATE 是个人信号;其它(普通消息 + 1501-1520 OpenIM 段位群广播事件)走 handleGroupMessage 入库 + 触发 applyGroupNotification 旁路 */ dispatchGroupFrame(websocketMessage: ImGroupMessageDTO) { - switch (websocketMessage.type) { - case ImMessageType.READ: - this.handleGroupRead(websocketMessage) - break - case ImMessageType.RECEIPT: - this.handleGroupReceipt(websocketMessage) - break - case ImMessageType.GROUP_MEMBER_SETTING_UPDATE: - this.handleGroupMemberSettingUpdate(websocketMessage) - break - default: - // TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT + GROUP_* 群广播事件 - this.handleGroupMessage(websocketMessage) + try { + switch (websocketMessage.type) { + case ImMessageType.READ: + this.handleGroupRead(websocketMessage) + break + case ImMessageType.RECEIPT: + this.handleGroupReceipt(websocketMessage) + break + case ImMessageType.GROUP_MEMBER_SETTING_UPDATE: + this.handleGroupMemberSettingUpdate(websocketMessage) + break + default: + // TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT + GROUP_* 群广播事件 + this.handleGroupMessage(websocketMessage) + } + } catch (e) { + // 单条帧的处理异常不应阻断后续帧;打印完整 websocketMessage 便于排查 + console.warn('[IM WS] dispatchGroupFrame 处理失败', websocketMessage, e) } }, @@ -461,43 +471,49 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { }) }, - // ==================== 好友关系事件(承载于私聊通道,按 inner type 分流) ==================== + // ==================== 好友通知(1201-1210 段位,承载于私聊通道) ==================== - /** FRIEND_ADD:后端推送给好友双方;本端拉取好友详情并入库,级联刷新私聊会话展示 */ - handleFriendAdd(websocketMessage: ImPrivateMessageDTO) { + /** + * 好友通知统一入口:解析 content 里的 payload,按 type 分发到 friendStore 内部 dispatcher + * + * 对应后端 ImPrivateMessageDTO.ofFriendNotification 系列;payload 实际类型见 + * BaseFriendNotification 子类(FriendRequestNotification / FriendAddNotification 等) + */ + handleFriendNotification(websocketMessage: ImPrivateMessageDTO) { + // content 解析失败由外层 dispatchPrivateFrame 的 try-catch 兜底(含 websocketMessage 打印),不重复 catch + const payload = JSON.parse(websocketMessage.content || '{}') as FriendNotificationPayload 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) + switch (websocketMessage.type) { + case ImMessageType.FRIEND_APPLICATION: + friendStore.applyFriendRequestNotification(payload) + break + case ImMessageType.FRIEND_REQUEST_APPROVED: + friendStore.applyFriendRequestApprovedNotification(payload) + break + case ImMessageType.FRIEND_REQUEST_REJECTED: + friendStore.applyFriendRequestRejectedNotification(payload) + break + case ImMessageType.FRIEND_ADD: + friendStore.applyFriendAddNotification(payload) + break + case ImMessageType.FRIEND_DELETE: + friendStore.applyFriendDeleteNotification(payload) + break + case ImMessageType.FRIEND_BLOCK: + friendStore.applyFriendBlockNotification(payload) + break + case ImMessageType.FRIEND_UNBLOCK: + friendStore.applyFriendUnblockNotification(payload) + break + case ImMessageType.FRIEND_INFO_UPDATED: + friendStore.applyFriendInfoUpdatedNotification(payload) + break + case ImMessageType.FRIEND_UPDATE: + friendStore.applyFriendUpdateNotification(payload) + break + default: + console.debug('[IM WS] 未识别好友通知', websocketMessage) + } }, // ==================== 群关系事件(承载于群聊通道,按 inner type 分流) ==================== @@ -508,13 +524,10 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { * payload 携带变更字段,按非 null 字段直接局部更新;省一次 fetchGroupMembers 接口 */ handleGroupMemberSettingUpdate(websocketMessage: ImGroupMessageDTO) { - let payload: { muted?: boolean; groupRemark?: string } = {} - try { - payload = JSON.parse(websocketMessage.content || '{}') - } catch (error) { - console.warn('[IM WS] handleGroupMemberSettingUpdate 解析 content 失败', error) - return - } + // content 解析失败由外层 dispatchGroupFrame 的 try-catch 兜底(含 websocketMessage 打印),不重复 catch + const payload: { muted?: boolean; groupRemark?: string } = JSON.parse( + websocketMessage.content || '{}' + ) const groupStore = useGroupStore() const group = groupStore.getGroup(websocketMessage.groupId) if (!group) { diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts index 8d7bd0ebf..7bb3481a4 100644 --- a/src/views/im/home/types/index.ts +++ b/src/views/im/home/types/index.ts @@ -153,10 +153,35 @@ export interface Friend { displayName?: string // 好友展示备注:仅自己可见的别名(单字段不歧义,不带 Friend 前缀) displayNamePinyin?: string // 备注的拼音(后端用 Pinyin4j 算好回填,小写无空格) status?: number // 好友状态,对齐 CommonStatusEnum(DISABLE = 已删除,软删保留记录) + addSource?: number // 添加来源;参见 ImFriendAddSourceEnum + pinned?: boolean // 是否置顶联系人 + blocked?: boolean // 是否拉黑(仅自己可见,单边屏蔽对方私聊消息) addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换) deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换) } +/** + * 好友申请记录(前端内部结构,对齐后端 ImFriendRequestRespVO) + */ +export interface FriendRequest { + // ========== 后端字段(对齐 ImFriendRequestRespVO) ========== + id: number // 申请编号 + fromUserId: number // 发起方用户编号 + toUserId: number // 接收方用户编号 + handleResult: number // 处理结果:0=未处理;1=同意;2=拒绝 + applyContent?: string // 申请理由(发起方填写) + handleContent?: string // 处理理由(接收方拒绝时可选填) + addSource?: number // 添加来源;参见 ImFriendAddSourceEnum + handleTime?: number // 处理时间(毫秒时间戳) + createTime: number // 申请创建时间(毫秒时间戳) + + // ========== 聚合字段(自 AdminUser,仅展示用) ========== + fromNickname?: string // 发起方昵称 + fromAvatar?: string // 发起方头像 + toNickname?: string // 接收方昵称 + toAvatar?: string // 接收方头像 +} + // ==================== 用户名片 ==================== // 用户精简信息(对齐后端 UserSimpleRespVO,名片 / 头像 hover 等场景共用) diff --git a/src/views/im/utils/constants.ts b/src/views/im/utils/constants.ts index 804a7df5c..5b8609bc6 100644 --- a/src/views/im/utils/constants.ts +++ b/src/views/im/utils/constants.ts @@ -10,9 +10,17 @@ export const ImMessageType = { RECEIPT: 12, // 回执 TIP_TIME: 20, // 时间分隔线(前端本地生成,不发送到后端) TIP_TEXT: 21, // 提示文本(撤回提示等) - FRIEND_ADD: 100, // 好友添加 - FRIEND_DELETE: 101, // 好友删除 - FRIEND_UPDATE: 102, // 好友更新(客户端收到后自行拉取) + // 好友通知(1201-1210 复用 OpenIM 段位编号) + FRIEND_REQUEST_APPROVED: 1201, // 好友申请被同意 + FRIEND_REQUEST_REJECTED: 1202, // 好友申请被拒绝 + FRIEND_APPLICATION: 1203, // 收到新的好友申请 + FRIEND_ADD: 1204, // 新增好友(双方建立关系) + FRIEND_DELETE: 1205, // 好友被删除 + // 1206 对应 OpenIM FriendRemarkSetNotification;本系统并入 FRIEND_UPDATE(1210) 统一推送 + FRIEND_BLOCK: 1207, // 加入黑名单 + FRIEND_UNBLOCK: 1208, // 移出黑名单 + FRIEND_INFO_UPDATED: 1209, // 好友资料变更(昵称 / 头像) + FRIEND_UPDATE: 1210, // 好友信息批量更新(muted / pinned) // 群事件(1501-1520 复用 OpenIM 段位编号;1530+ 自有扩展段) GROUP_CREATE: 1501, // 群创建 GROUP_INFO_UPDATE: 1502, // 群信息变更(NAME / NOTICE 之外字段兜底) @@ -48,6 +56,11 @@ export function isGroupNotification(type: number): boolean { ) } +/** 判断是否「好友通知事件」:1201-1210 段位 */ +export function isFriendNotification(type: number): boolean { + return type >= ImMessageType.FRIEND_REQUEST_APPROVED && type <= ImMessageType.FRIEND_UPDATE +} + /** IM 普通消息类型集合(聊天气泡中显示,并作为会话最后一条摘要) */ const ImMessageTypeNormals: number[] = [ ImMessageType.TEXT,