feat(im): 修一批状态串扰:群申请列表防同群 WS 推送乱序覆盖、群免打扰同步会话、跨端群已读清 @、联系人 selection 跟随 store、好友申请按钮并发锁

im
YunaiV 2026-05-21 11:11:36 +08:00
parent 1e08e9fbca
commit 29b257b8cd
6 changed files with 134 additions and 33 deletions

View File

@ -12,10 +12,7 @@
:close-on-click-modal="false" :close-on-click-modal="false"
class="im-group-request-list__dialog" class="im-group-request-list__dialog"
> >
<div <div v-loading="loading" class="flex flex-col gap-3 max-h-[60vh] overflow-y-auto pr-1">
v-loading="loading"
class="flex flex-col gap-3 max-h-[60vh] overflow-y-auto pr-1"
>
<!-- 空态 --> <!-- 空态 -->
<el-empty v-if="!loading && list.length === 0" description="暂无进群申请" /> <el-empty v-if="!loading && list.length === 0" description="暂无进群申请" />
@ -32,10 +29,14 @@
:clickable="false" :clickable="false"
/> />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="truncate text-sm font-medium leading-[1.4] text-[var(--el-text-color-primary)]"> <div
class="truncate text-sm font-medium leading-[1.4] text-[var(--el-text-color-primary)]"
>
{{ latest.userNickname || `用户 ${latest.userId}` }} {{ latest.userNickname || `用户 ${latest.userId}` }}
</div> </div>
<div class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--el-text-color-secondary)]"> <div
class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--el-text-color-secondary)]"
>
<template v-if="latest.inviterUserId"> <template v-if="latest.inviterUserId">
通过 通过
<span class="text-[var(--el-color-primary)]"> <span class="text-[var(--el-color-primary)]">
@ -113,10 +114,14 @@
:clickable="false" :clickable="false"
/> />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="truncate text-sm font-medium leading-[1.4] text-[var(--el-text-color-primary)]"> <div
class="truncate text-sm font-medium leading-[1.4] text-[var(--el-text-color-primary)]"
>
{{ item.userNickname || `用户 ${item.userId}` }} {{ item.userNickname || `用户 ${item.userId}` }}
</div> </div>
<div class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--el-text-color-secondary)]"> <div
class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--el-text-color-secondary)]"
>
<template v-if="item.inviterUserId"> <template v-if="item.inviterUserId">
通过 通过
<span class="text-[var(--el-color-primary)]"> <span class="text-[var(--el-color-primary)]">
@ -225,7 +230,10 @@ watch(
groupId.value && visible.value groupId.value && visible.value
? groupRequestStore.unhandledList ? groupRequestStore.unhandledList
.filter((request) => request.groupId === groupId.value) .filter((request) => request.groupId === groupId.value)
.map((request) => `${request.id}:${request.inviterUserId ?? ''}:${request.applyContent ?? ''}`) .map(
(request) =>
`${request.id}:${request.inviterUserId ?? ''}:${request.applyContent ?? ''}`
)
.join(',') .join(',')
: null, : null,
(current, previous) => { (current, previous) => {
@ -241,12 +249,22 @@ watch(
} }
) )
async function fetchList(groupId: number) { let fetchSeq = 0 // WS 1503 fetch
async function fetchList(targetGroupId: number) {
const seq = ++fetchSeq
loading.value = true loading.value = true
try { try {
groupList.value = (await getGroupRequestListByGroupId(groupId)) || [] const data = (await getGroupRequestListByGroupId(targetGroupId)) || []
// / / fetch
if (seq !== fetchSeq || !visible.value || groupId.value !== targetGroupId) {
return
}
groupList.value = data
} finally { } finally {
loading.value = false // finally loading
if (seq === fetchSeq) {
loading.value = false
}
} }
} }

View File

@ -140,22 +140,38 @@ const peerUser = computed<User>(() => ({
avatar: peerAvatar.value avatar: peerAvatar.value
})) }))
// loading spinner processing /
const agreeing = ref(false) const agreeing = ref(false)
const refusing = ref(false) const refusing = ref(false)
const processing = ref(false)
/** 同意申请 */ /** 同意申请:互斥锁 + 状态二次校验,避免并发 / 服务端已处理后再次提交 */
async function handleAgree() { async function handleAgree() {
if (processing.value) {
return
}
if (props.request.handleResult !== ImFriendRequestHandleResult.UNHANDLED) {
return
}
processing.value = true
agreeing.value = true agreeing.value = true
try { try {
await friendStore.agreeFriendRequest(props.request.id) await friendStore.agreeFriendRequest(props.request.id)
message.success('已同意好友申请') message.success('已同意好友申请')
} finally { } finally {
agreeing.value = false agreeing.value = false
processing.value = false
} }
} }
/** 拒绝申请:弹 prompt 收集可选拒绝理由(点取消则中止),随后调 store 落库 + 提示 */ /** 拒绝申请:弹 prompt 收集可选拒绝理由(点取消则中止),随后调 store 落库 + 提示 */
async function handleRefuse() { async function handleRefuse() {
if (processing.value) {
return
}
if (props.request.handleResult !== ImFriendRequestHandleResult.UNHANDLED) {
return
}
// 1. prompt 255 reject // 1. prompt 255 reject
let handleContent: string | undefined let handleContent: string | undefined
try { try {
@ -171,13 +187,21 @@ async function handleRefuse() {
} catch { } catch {
return return
} }
// 2. store loading // 2. prompt AGREED / REFUSED
if (processing.value) {
return
}
if (props.request.handleResult !== ImFriendRequestHandleResult.UNHANDLED) {
return
}
processing.value = true
refusing.value = true refusing.value = true
try { try {
await friendStore.refuseFriendRequest(props.request.id, handleContent) await friendStore.refuseFriendRequest(props.request.id, handleContent)
message.success('已拒绝好友申请') message.success('已拒绝好友申请')
} finally { } finally {
refusing.value = false refusing.value = false
processing.value = false
} }
} }
</script> </script>

View File

@ -86,7 +86,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue' import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage' import { useMessage } from '@/hooks/web/useMessage'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@ -149,6 +149,57 @@ const groups = computed<GroupLite[]>(() =>
})) }))
) )
/**
* store 列表变化时同步 selection 持的对象副本对端推送 / 跨端动作改 store 右侧详情能跟上
* - 命中则替换为最新引用资料 / 备注 / 申请状态变更立刻反映
* - 找不到删除 / 拒绝 / 已通过等让记录消失则置 null 收起详情
*/
watch(
friends,
(list) => {
if (selection.value?.type !== 'friend') {
return
}
const fresh = list.find((friend) => friend.id === selection.value!.friend.id)
if (!fresh) {
selection.value = null
} else if (fresh !== selection.value.friend) {
selection.value = { type: 'friend', friend: fresh }
}
},
{ deep: true }
)
watch(
groups,
(list) => {
if (selection.value?.type !== 'group') {
return
}
const fresh = list.find((group) => group.id === selection.value!.group.id)
if (!fresh) {
selection.value = null
} else if (fresh !== selection.value.group) {
selection.value = { type: 'group', group: fresh }
}
},
{ deep: true }
)
watch(
friendRequests,
(list) => {
if (selection.value?.type !== 'request') {
return
}
const fresh = list.find((request) => request.id === selection.value!.request.id)
if (!fresh) {
selection.value = null
} else if (fresh !== selection.value.request) {
selection.value = { type: 'request', request: fresh }
}
},
{ deep: true }
)
const friendUser = computed<User | null>(() => { const friendUser = computed<User | null>(() => {
if (selection.value?.type !== 'friend') { if (selection.value?.type !== 'friend') {
return null return null

View File

@ -855,6 +855,26 @@ export const useConversationStore = defineStore('imConversationStore', {
this.saveConversations(conversation) this.saveConversations(conversation)
}, },
/**
* READ "全已读"unread + atMe + atAll @
*
* markActiveAsRead messages status conversation
* / @ @ false noop saveConversations
*/
markConversationAsRead(type: number, targetId: number) {
const conversation = this.getConversation(type, targetId)
if (!conversation) {
return
}
if (conversation.unreadCount === 0 && !conversation.atMe && !conversation.atAll) {
return
}
conversation.unreadCount = 0
conversation.atMe = false
conversation.atAll = false
this.saveConversations(conversation)
},
/** /**
* / * /
* *

View File

@ -399,7 +399,7 @@ export const useGroupStore = defineStore('imGroupStore', {
} }
}, },
/** 切换免打扰:推后端 + 落本地 */ /** 切换免打扰:推后端 + 落本地 + 同步会话列表的 silent避免 silent 图标 / 总未读 / 提示音判断与设置漂移;和 friendStore.setSilent 对齐 */
async setSilent(id: number, silent: boolean) { async setSilent(id: number, silent: boolean) {
await apiUpdateGroupMember({ groupId: id, silent }) await apiUpdateGroupMember({ groupId: id, silent })
const group = this.getGroup(id) const group = this.getGroup(id)
@ -407,6 +407,8 @@ export const useGroupStore = defineStore('imGroupStore', {
return return
} }
group.silent = silent group.silent = silent
const conversationStore = useConversationStore()
conversationStore.updateConversation(ImConversationType.GROUP, id, { silent })
this.saveGroups() this.saveGroups()
}, },

View File

@ -503,14 +503,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
return return
} }
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const conversation = conversationStore.getConversation( conversationStore.markConversationAsRead(ImConversationType.PRIVATE, websocketMessage.receiverId)
ImConversationType.PRIVATE,
websocketMessage.receiverId
)
if (conversation) {
conversation.unreadCount = 0
}
conversationStore.saveConversations()
}, },
/** /**
@ -614,20 +607,13 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// ==================== 群聊已读 / 回执 ==================== // ==================== 群聊已读 / 回执 ====================
/** 群聊 READ自己其它终端在某群里标为已读本端同步清零该群未读;群已读关闭时兜底忽略 */ /** 群聊 READ自己其它终端在某群里标为已读本端同步清零该群未读 + @ 红字;群已读关闭时兜底忽略 */
handleGroupRead(websocketMessage: ImGroupMessageDTO) { handleGroupRead(websocketMessage: ImGroupMessageDTO) {
if (!MESSAGE_GROUP_READ_ENABLED) { if (!MESSAGE_GROUP_READ_ENABLED) {
return return
} }
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const conversation = conversationStore.getConversation( conversationStore.markConversationAsRead(ImConversationType.GROUP, websocketMessage.groupId)
ImConversationType.GROUP,
websocketMessage.groupId
)
if (conversation) {
conversation.unreadCount = 0
}
conversationStore.saveConversations()
}, },
/** 群聊 RECEIPT更新某条群消息的 readCount / receiptStatus群已读关闭时兜底忽略 */ /** 群聊 RECEIPT更新某条群消息的 readCount / receiptStatus群已读关闭时兜底忽略 */