feat(im):好友模块 code review 多项修复(补 block/unblock 全链路 + UserInfo 菜单入口、silent 后主动入库、防 currentUserId 切账号失活、雪崩去重与命名/枚举清理)

im
YunaiV 2026-05-04 17:31:21 +08:00
parent 5c2a185ff9
commit 7618d58a66
7 changed files with 71 additions and 62 deletions

View File

@ -20,15 +20,14 @@
<el-scrollbar v-loading="loading" class="h-[400px] mt-2.5"> <el-scrollbar v-loading="loading" class="h-[400px] mt-2.5">
<div <div
v-if="users.length === 0" v-if="visibleUsers.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]" class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
> >
{{ searched ? '没有搜到用户' : '输入关键字后回车开始搜索' }} {{ searched ? '没有搜到用户' : '输入关键字后回车开始搜索' }}
</div> </div>
<div <div
v-for="user in users" v-for="user in visibleUsers"
:key="user.id" :key="user.id"
v-show="user.id !== currentUserId"
class="flex gap-3 items-center px-2 py-2.5 border-b border-[var(--el-border-color-lighter)]" class="flex gap-3 items-center px-2 py-2.5 border-b border-[var(--el-border-color-lighter)]"
> >
<UserAvatar <UserAvatar
@ -174,6 +173,9 @@ const message = useMessage()
/** 当前登录用户编号;用 computed 包一层,切账号后随 wsCache 重取,避免顶层求值在 keep-alive 实例里持有旧 id */ /** 当前登录用户编号;用 computed 包一层,切账号后随 wsCache 重取,避免顶层求值在 keep-alive 实例里持有旧 id */
const currentUserId = computed(() => getCurrentUserId()) const currentUserId = computed(() => getCurrentUserId())
/** 搜索结果过滤掉自己;用 v-if 而非 v-show避免 DOM 占位 + 头像无效请求 */
const visibleUsers = computed(() => users.value.filter((u) => u.id !== currentUserId.value))
const keyword = ref('') const keyword = ref('')
const users = ref<UserVO[]>([]) const users = ref<UserVO[]>([])
const searched = ref(false) const searched = ref(false)

View File

@ -180,6 +180,7 @@ import UserAvatar from './UserAvatar.vue'
import FriendAddDialog from '../friend/FriendAddDialog.vue' import FriendAddDialog from '../friend/FriendAddDialog.vue'
import { getSimpleUser, type UserVO } from '@/api/system/user' import { getSimpleUser, type UserVO } from '@/api/system/user'
import { useFriendStore } from '../../store/friendStore' import { useFriendStore } from '../../store/friendStore'
import { ImFriendAddSource } from '../../../utils/constants'
import { getGenderColor, getGenderIcon } from '../../../utils/user' import { getGenderColor, getGenderIcon } from '../../../utils/user'
import { DICT_TYPE, getDictLabel } from '@/utils/dict' import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import { formatDate } from '@/utils/formatTime' import { formatDate } from '@/utils/formatTime'
@ -205,15 +206,15 @@ const props = withDefaults(
displayName?: string displayName?: string
/** UserAvatar 预览层 z-index放在高 z-index 浮层(如 UserInfoCard里需手动抬高 */ /** UserAvatar 预览层 z-index放在高 z-index 浮层(如 UserInfoCard里需手动抬高 */
previewZIndex?: number previewZIndex?: number
/** 加好友来源1=搜索 2=群聊 3=扫码 4=名片;默认 1搜索参见 ImFriendAddSourceEnum */ /** 加好友来源;默认 SEARCH参见 ImFriendAddSource */
addSource?: number addSource?: number
/** 来源附带信息addSource=2(群聊)时传群名,用于「我是 XX 群的 YY」预填话术 */ /** 来源附带信息addSource=GROUP(群聊)时传群名,用于「我是 XX 群的 YY」预填话术 */
addSourceExtra?: string addSourceExtra?: string
}>(), }>(),
{ {
relation: 'readonly', relation: 'readonly',
previewZIndex: 2000, previewZIndex: 2000,
addSource: 1 addSource: ImFriendAddSource.SEARCH
} }
) )

View File

@ -107,7 +107,7 @@ const message = useMessage()
/** 当前登录用户编号;用 computed 包一层,切账号后随 wsCache 重取,避免顶层求值在 keep-alive 实例里持有旧 id */ /** 当前登录用户编号;用 computed 包一层,切账号后随 wsCache 重取,避免顶层求值在 keep-alive 实例里持有旧 id */
const currentUserId = computed(() => getCurrentUserId()) const currentUserId = computed(() => getCurrentUserId())
/** 是不是我发起的fromUserId === me */ /** 是不是我发起的fromUserId === currentUserId */
const iSentIt = computed(() => props.request.fromUserId === currentUserId.value) const iSentIt = computed(() => props.request.fromUserId === currentUserId.value)
/** 是否「已拒绝」态模板里多处用到computed 一次省得到处写枚举比对 */ /** 是否「已拒绝」态模板里多处用到computed 一次省得到处写枚举比对 */

View File

@ -13,13 +13,18 @@
> >
<Icon :icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'" :size="14" /> <Icon :icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'" :size="14" />
<span class="flex-1">新的朋友</span> <span class="flex-1">新的朋友</span>
<!-- 红点未处理且别人加我的 --> <!-- 红点未处理且别人加我的统一走 store getter避免本地 computed store 双口径 -->
<el-badge v-if="unhandledCount > 0" :value="unhandledCount" :max="99" class="mr-2" /> <el-badge
v-if="friendStore.getUnhandledRequestCount > 0"
:value="friendStore.getUnhandledRequestCount"
:max="99"
class="mr-2"
/>
<span class="text-sm text-[var(--el-text-color-secondary)]">{{ requests.length }}</span> <span class="text-sm text-[var(--el-text-color-secondary)]">{{ requests.length }}</span>
</div> </div>
<div v-show="expanded"> <div v-show="expanded">
<div <div
v-for="request in requests" v-for="{ request, peer } in enrichedRequests"
:key="request.id" :key="request.id"
class="flex gap-3 items-start px-3.5 py-2.5 cursor-pointer transition-colors hover:bg-[var(--el-fill-color-light)]" class="flex gap-3 items-start px-3.5 py-2.5 cursor-pointer transition-colors hover:bg-[var(--el-fill-color-light)]"
:class="{ :class="{
@ -28,16 +33,16 @@
@click="emit('select', request)" @click="emit('select', request)"
> >
<UserAvatar <UserAvatar
:id="getPeerUserId(request)" :id="peer.id"
:url="getPeerAvatar(request)" :url="peer.avatar"
:name="getPeerNickname(request)" :name="peer.nickname"
:size="36" :size="36"
:clickable="false" :clickable="false"
/> />
<div class="flex-1 min-w-0 overflow-hidden"> <div class="flex-1 min-w-0 overflow-hidden">
<div class="flex justify-between gap-2 items-center"> <div class="flex justify-between gap-2 items-center">
<span class="flex-1 text-sm font-medium truncate text-[var(--el-text-color-primary)]"> <span class="flex-1 text-sm font-medium truncate text-[var(--el-text-color-primary)]">
{{ getPeerNickname(request) }} {{ peer.nickname }}
</span> </span>
<span class="flex-shrink-0 text-12px text-[var(--el-text-color-secondary)]"> <span class="flex-shrink-0 text-12px text-[var(--el-text-color-secondary)]">
{{ getDictLabel(DICT_TYPE.IM_FRIEND_REQUEST_HANDLE_RESULT, request.handleResult) }} {{ getDictLabel(DICT_TYPE.IM_FRIEND_REQUEST_HANDLE_RESULT, request.handleResult) }}
@ -65,8 +70,8 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue' import Icon from '@/components/Icon/src/Icon.vue'
import UserAvatar from '../../components/user/UserAvatar.vue' import UserAvatar from '../../components/user/UserAvatar.vue'
import { useFriendStore } from '../../store/friendStore'
import { getCurrentUserId } from '../../../utils/storage' import { getCurrentUserId } from '../../../utils/storage'
import { ImFriendRequestHandleResult } from '../../../utils/constants'
import { DICT_TYPE, getDictLabel } from '@/utils/dict' import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import type { FriendRequest } from '../../types' import type { FriendRequest } from '../../types'
@ -81,32 +86,26 @@ const emit = defineEmits<{
select: [request: FriendRequest] select: [request: FriendRequest]
}>() }>()
const friendStore = useFriendStore()
const expanded = ref(true) const expanded = ref(true)
/** 当前登录用户编号;用 computed 包一层,切账号后随 wsCache 重取,避免顶层求值在 keep-alive 实例里持有旧 id */ /** 当前登录用户编号;用 computed 包一层,切账号后随 wsCache 重取,避免顶层求值在 keep-alive 实例里持有旧 id */
const currentUserId = computed(() => getCurrentUserId()) const currentUserId = computed(() => getCurrentUserId())
/** 未处理 + 别人加我的(接收方=我)才进红点;我发起的不进 */ /** 列表项展示对端fromUserId == 我 → 对端 = toUser否则对端 = fromUser */
const unhandledCount = computed( function getPeer(request: FriendRequest) {
() => props.requests.filter( const sentByMe = request.fromUserId === currentUserId.value
(r) => r.handleResult === ImFriendRequestHandleResult.UNHANDLED && r.toUserId === currentUserId.value return {
).length id: sentByMe ? request.toUserId : request.fromUserId,
nickname: sentByMe
? request.toNickname || String(request.toUserId)
: request.fromNickname || String(request.fromUserId),
avatar: sentByMe ? request.toAvatar : request.fromAvatar
}
}
/** 列表项预先附 peer 字段,模板里直接 {{ peer.xxx }} 一次成型,省 3 次 helper 调用 */
const enrichedRequests = computed(() =>
props.requests.map((request) => ({ request, peer: getPeer(request) }))
) )
/** 列表项展示对端fromUserId == 我 → 对端 = toUser否则对端 = fromUser */
function getPeerUserId(request: FriendRequest): number {
return request.fromUserId === currentUserId.value ? request.toUserId : request.fromUserId
}
/** 列表项展示对端的昵称fromUserId == 我 → toUser 昵称;否则 fromUser 昵称;缺则用 id 兜底) */
function getPeerNickname(request: FriendRequest): string {
return request.fromUserId === currentUserId.value
? request.toNickname || String(request.toUserId)
: request.fromNickname || String(request.fromUserId)
}
/** 列表项展示对端的头像fromUserId == 我 → toUser 头像;否则 fromUser 头像) */
function getPeerAvatar(request: FriendRequest): string | undefined {
return request.fromUserId === currentUserId.value ? request.toAvatar : request.fromAvatar
}
</script> </script>

View File

@ -20,17 +20,13 @@ import {
type ImFriendRequestRespVO type ImFriendRequestRespVO
} from '@/api/im/friend/request' } from '@/api/im/friend/request'
import { useConversationStore } from './conversationStore' import { useConversationStore } from './conversationStore'
import { ImConversationType } from '../../utils/constants' import { ImConversationType, ImFriendRequestHandleResult } from '../../utils/constants'
import { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage' import { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage'
import { getFriendDisplayName } from '../../utils/user' import { getFriendDisplayName } from '../../utils/user'
import type { Friend, FriendRequest } from '../types' import type { Friend, FriendRequest } from '../types'
/** 好友申请处理结果(对齐后端 ImFriendRequestHandleResultEnum */ /** 当前正在进行的好友申请列表拉取;多端连续多条申请到达时复用同一 Promise避免雪崩重拉 */
const FriendRequestHandleResult = { let inflightFetchRequests: Promise<void> | null = null
UNHANDLED: 0,
AGREED: 1,
REFUSED: 2
} as const
/** 好友通知 payload对齐后端 BaseFriendNotification + 子类裁减后的字段) */ /** 好友通知 payload对齐后端 BaseFriendNotification + 子类裁减后的字段) */
export interface FriendNotificationPayload { export interface FriendNotificationPayload {
@ -90,9 +86,11 @@ export const useFriendStore = defineStore('imFriendStore', {
}, },
/** 未处理申请数(接收方=我)—— 实时派生,「新的朋友」红点用 */ /** 未处理申请数(接收方=我)—— 实时派生,「新的朋友」红点用 */
getUnhandledRequestCount: (state): number => { getUnhandledRequestCount: (state): number => {
const me = Number(getCurrentUserId() || 0) const currentUserId = Number(getCurrentUserId() || 0)
return state.friendRequests.filter( return state.friendRequests.filter(
(r) => r.handleResult === FriendRequestHandleResult.UNHANDLED && r.toUserId === me (request) =>
request.handleResult === ImFriendRequestHandleResult.UNHANDLED &&
request.toUserId === currentUserId
).length ).length
} }
}, },
@ -175,7 +173,7 @@ export const useFriendStore = defineStore('imFriendStore', {
await apiAgreeFriendRequest(requestId) await apiAgreeFriendRequest(requestId)
const request = this.findFriendRequest(requestId) const request = this.findFriendRequest(requestId)
if (request) { if (request) {
request.handleResult = FriendRequestHandleResult.AGREED request.handleResult = ImFriendRequestHandleResult.AGREED
request.handleTime = Date.now() request.handleTime = Date.now()
} else { } else {
// 列表过期场景兜底重拉 // 列表过期场景兜底重拉
@ -189,7 +187,7 @@ export const useFriendStore = defineStore('imFriendStore', {
await apiRefuseFriendRequest(requestId, handleContent) await apiRefuseFriendRequest(requestId, handleContent)
const request = this.findFriendRequest(requestId) const request = this.findFriendRequest(requestId)
if (request) { if (request) {
request.handleResult = FriendRequestHandleResult.REFUSED request.handleResult = ImFriendRequestHandleResult.REFUSED
request.handleContent = handleContent request.handleContent = handleContent
request.handleTime = Date.now() request.handleTime = Date.now()
} else { } else {
@ -197,16 +195,24 @@ export const useFriendStore = defineStore('imFriendStore', {
} }
}, },
/** 拉取「我相关」的好友申请列表(页面打开时 / 收到 FRIEND_APPLICATION 时刷新) */ /** 拉取「我相关」的好友申请列表(页面打开时 / 收到 FRIEND_APPLICATION 时刷新)in-flight 期间复用同一 Promise */
async fetchFriendRequests() { async fetchFriendRequests() {
const list = await apiGetMyFriendRequestList() if (inflightFetchRequests) {
this.friendRequests = (list || []).map(convertFriendRequest) return inflightFetchRequests
}
inflightFetchRequests = apiGetMyFriendRequestList()
.then((list) => {
this.friendRequests = (list || []).map(convertFriendRequest)
})
.finally(() => {
inflightFetchRequests = null
})
return inflightFetchRequests
}, },
/** 按 id 查申请记录 */ /** 按 id 查申请记录;列表是按 id 倒序的小列表O(n) find 即可,不再维护 Map 索引 */
findFriendRequest(requestId: number): FriendRequest | undefined { findFriendRequest(requestId: number): FriendRequest | undefined {
// TODO @AIrequest return this.friendRequests.find((request) => request.id === requestId)
return this.friendRequests.find((r) => r.id === requestId)
}, },
// ==================== 好友关系操作 ==================== // ==================== 好友关系操作 ====================
@ -323,7 +329,7 @@ export const useFriendStore = defineStore('imFriendStore', {
applyFriendRequestApprovedNotification(payload: FriendNotificationPayload) { applyFriendRequestApprovedNotification(payload: FriendNotificationPayload) {
const request = payload.requestId ? this.findFriendRequest(payload.requestId) : undefined const request = payload.requestId ? this.findFriendRequest(payload.requestId) : undefined
if (request) { if (request) {
request.handleResult = FriendRequestHandleResult.AGREED request.handleResult = ImFriendRequestHandleResult.AGREED
request.handleTime = Date.now() request.handleTime = Date.now()
} else { } else {
this.fetchFriendRequests().catch(() => undefined) this.fetchFriendRequests().catch(() => undefined)
@ -334,7 +340,7 @@ export const useFriendStore = defineStore('imFriendStore', {
applyFriendRequestRejectedNotification(payload: FriendNotificationPayload) { applyFriendRequestRejectedNotification(payload: FriendNotificationPayload) {
const request = payload.requestId ? this.findFriendRequest(payload.requestId) : undefined const request = payload.requestId ? this.findFriendRequest(payload.requestId) : undefined
if (request) { if (request) {
request.handleResult = FriendRequestHandleResult.REFUSED request.handleResult = ImFriendRequestHandleResult.REFUSED
request.handleContent = payload.handleContent request.handleContent = payload.handleContent
request.handleTime = Date.now() request.handleTime = Date.now()
} else { } else {

View File

@ -238,7 +238,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
/** /**
* payload.typeImMessageType / / / * payload.typeImMessageType / / /
* *
* 1530 GROUP_MEMBER_SETTING_UPDATE + 1501-1520 OpenIM 广 handleGroupMessage + applyGroupNotification * 1530 GROUP_MEMBER_SETTING_UPDATE + 1501-1520 广 handleGroupMessage + applyGroupNotification
*/ */
dispatchGroupFrame(websocketMessage: ImGroupMessageDTO) { dispatchGroupFrame(websocketMessage: ImGroupMessageDTO) {
try { try {

View File

@ -9,18 +9,18 @@ export const ImMessageType = {
READ: 11, // 已读 READ: 11, // 已读
RECEIPT: 12, // 回执 RECEIPT: 12, // 回执
TIP_TEXT: 21, // 提示文本(撤回提示等) TIP_TEXT: 21, // 提示文本(撤回提示等)
// 好友通知1201-1210 复用 OpenIM 段位编号) // ========== 好友通知1201-1210 直接复用 OpenIM 段位编号) ==========
FRIEND_REQUEST_APPROVED: 1201, // 好友申请被同意 FRIEND_REQUEST_APPROVED: 1201, // 好友申请被同意
FRIEND_REQUEST_REJECTED: 1202, // 好友申请被拒绝 FRIEND_REQUEST_REJECTED: 1202, // 好友申请被拒绝
FRIEND_APPLICATION: 1203, // 收到新的好友申请 FRIEND_APPLICATION: 1203, // 收到新的好友申请
FRIEND_ADD: 1204, // 新增好友(双方建立关系) FRIEND_ADD: 1204, // 新增好友(双方建立关系)
FRIEND_DELETE: 1205, // 好友被删除 FRIEND_DELETE: 1205, // 好友被删除
// 1206 对应 OpenIM FriendRemarkSetNotification本系统并入 FRIEND_UPDATE(1210) 统一推送 // 1206 对应 OpenIM FriendRemarkSetNotification本系统并入 FRIEND_UPDATE(1210) 统一推送,单一字段变更不再独立通道
FRIEND_BLOCK: 1207, // 加入黑名单 FRIEND_BLOCK: 1207, // 加入黑名单
FRIEND_UNBLOCK: 1208, // 移出黑名单 FRIEND_UNBLOCK: 1208, // 移出黑名单
FRIEND_INFO_UPDATED: 1209, // 好友资料变更(昵称 / 头像) FRIEND_INFO_UPDATED: 1209, // 好友资料变更(昵称 / 头像)
FRIEND_UPDATE: 1210, // 好友信息批量更新muted / pinned FRIEND_UPDATE: 1210, // 好友信息批量更新muted / pinned
// 群事件1501-1520 复用 OpenIM 段位编号1530+ 自有扩展段) // ========== 群事件1501-1520 直接复用 OpenIM 段位编号1530+ 自有扩展段) ==========
GROUP_CREATE: 1501, // 群创建 GROUP_CREATE: 1501, // 群创建
GROUP_INFO_UPDATE: 1502, // 群信息变更NAME / NOTICE 之外字段兜底) GROUP_INFO_UPDATE: 1502, // 群信息变更NAME / NOTICE 之外字段兜底)
// 1503 GROUP_JOIN_APPLICATION TODO 未实现:入群申请 // 1503 GROUP_JOIN_APPLICATION TODO 未实现:入群申请
@ -41,9 +41,10 @@ export const ImMessageType = {
GROUP_ADMIN_REMOVE: 1518, // 撤销管理员 GROUP_ADMIN_REMOVE: 1518, // 撤销管理员
GROUP_NOTICE_UPDATE: 1519, // 群公告变更 GROUP_NOTICE_UPDATE: 1519, // 群公告变更
GROUP_NAME_UPDATE: 1520, // 群名变更 GROUP_NAME_UPDATE: 1520, // 群名变更
// ========== 自有扩展段1530+OpenIM 1500-1520 段位无对应物) ==========
GROUP_MEMBER_SETTING_UPDATE: 1530, // 群成员个人设置变更muted / groupRemark 个人多端同步 GROUP_MEMBER_SETTING_UPDATE: 1530, // 群成员个人设置变更muted / groupRemark 个人多端同步
GROUP_MESSAGE_PIN: 1531, // 群消息置顶 GROUP_MESSAGE_PIN: 1531, // 群消息置顶自有扩展OpenIM 无)
GROUP_MESSAGE_UNPIN: 1532 // 群消息取消置顶 GROUP_MESSAGE_UNPIN: 1532 // 群消息取消置顶自有扩展OpenIM 无)
} as const } as const
/** 判断是否「群广播事件」:[GROUP_CREATE, GROUP_MESSAGE_UNPIN] 段位都算,仅 GROUP_MEMBER_SETTING_UPDATE 是个人信号排除 */ /** 判断是否「群广播事件」:[GROUP_CREATE, GROUP_MESSAGE_UNPIN] 段位都算,仅 GROUP_MEMBER_SETTING_UPDATE 是个人信号排除 */