✨ feat(im): 修一批状态串扰:群申请列表防同群 WS 推送乱序覆盖、群免打扰同步会话、跨端群已读清 @、联系人 selection 跟随 store、好友申请按钮并发锁
parent
1e08e9fbca
commit
29b257b8cd
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
||||
/**
|
||||
* 当前会话全部标记为已读(切换会话 / 手动触发)
|
||||
* 只处理「对方发来的、尚未读」的消息
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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;群已读关闭时兜底忽略 */
|
||||
|
|
|
|||
Loading…
Reference in New Issue