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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,18 +9,18 @@ export const ImMessageType = {
READ: 11, // 已读
RECEIPT: 12, // 回执
TIP_TEXT: 21, // 提示文本(撤回提示等)
// 好友通知1201-1210 复用 OpenIM 段位编号)
// ========== 好友通知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) 统一推送
// 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+ 自有扩展段)
// ========== 群事件1501-1520 直接复用 OpenIM 段位编号1530+ 自有扩展段) ==========
GROUP_CREATE: 1501, // 群创建
GROUP_INFO_UPDATE: 1502, // 群信息变更NAME / NOTICE 之外字段兜底)
// 1503 GROUP_JOIN_APPLICATION TODO 未实现:入群申请
@ -41,9 +41,10 @@ export const ImMessageType = {
GROUP_ADMIN_REMOVE: 1518, // 撤销管理员
GROUP_NOTICE_UPDATE: 1519, // 群公告变更
GROUP_NAME_UPDATE: 1520, // 群名变更
// ========== 自有扩展段1530+OpenIM 1500-1520 段位无对应物) ==========
GROUP_MEMBER_SETTING_UPDATE: 1530, // 群成员个人设置变更muted / groupRemark 个人多端同步
GROUP_MESSAGE_PIN: 1531, // 群消息置顶
GROUP_MESSAGE_UNPIN: 1532 // 群消息取消置顶
GROUP_MESSAGE_PIN: 1531, // 群消息置顶自有扩展OpenIM 无)
GROUP_MESSAGE_UNPIN: 1532 // 群消息取消置顶自有扩展OpenIM 无)
} as const
/** 判断是否「群广播事件」:[GROUP_CREATE, GROUP_MESSAGE_UNPIN] 段位都算,仅 GROUP_MEMBER_SETTING_UPDATE 是个人信号排除 */