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"
class="im-group-request-list__dialog"
>
<div
v-loading="loading"
class="flex flex-col gap-3 max-h-[60vh] overflow-y-auto pr-1"
>
<div 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="暂无进群申请" />
@ -32,10 +29,14 @@
:clickable="false"
/>
<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}` }}
</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">
通过
<span class="text-[var(--el-color-primary)]">
@ -113,10 +114,14 @@
:clickable="false"
/>
<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}` }}
</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">
通过
<span class="text-[var(--el-color-primary)]">
@ -225,7 +230,10 @@ watch(
groupId.value && visible.value
? groupRequestStore.unhandledList
.filter((request) => request.groupId === groupId.value)
.map((request) => `${request.id}:${request.inviterUserId ?? ''}:${request.applyContent ?? ''}`)
.map(
(request) =>
`${request.id}:${request.inviterUserId ?? ''}:${request.applyContent ?? ''}`
)
.join(',')
: null,
(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
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 {
loading.value = false
// finally loading
if (seq === fetchSeq) {
loading.value = false
}
}
}

View File

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

View File

@ -86,7 +86,7 @@
</template>
<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 { useMessage } from '@/hooks/web/useMessage'
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>(() => {
if (selection.value?.type !== 'friend') {
return null

View File

@ -855,6 +855,26 @@ export const useConversationStore = defineStore('imConversationStore', {
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) {
await apiUpdateGroupMember({ groupId: id, silent })
const group = this.getGroup(id)
@ -407,6 +407,8 @@ export const useGroupStore = defineStore('imGroupStore', {
return
}
group.silent = silent
const conversationStore = useConversationStore()
conversationStore.updateConversation(ImConversationType.GROUP, id, { silent })
this.saveGroups()
},

View File

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