feat(im): 新增 MessageReadStatus.vue

im
YunaiV 2026-04-27 22:36:47 +08:00
parent bfa267120a
commit 8847cdb79f
1 changed files with 102 additions and 63 deletions

View File

@ -1,9 +1,10 @@
<template>
<!--
群消息已读状态对应 boxim chat/ChatGroupReaded.vue
- 标签形态展示N 已读全部已读点击弹出 tab 列出具体成员
- 仅在群聊自己发送已送达的消息下使用
- 依赖 getGroupReadUsers API 拉已读人列表未读列表由群成员减去已读得出
群消息已读状态
- 标签形态展示N 已读全部已读点击弹出 popover tab 列出已读 / 未读成员
- 渲染条件仅在群聊自己发送receiptStatus !== NO_RECEIPT 时挂出 MessageItem 把关
- 已读名单按需拉popover 触发 show 才打 /im/message/group/get-read-users避免每条群消息都预拉
- 未读名单 = 当前群成员 自己 已退群 已读集合前端聚合后端只回已读 userId
-->
<el-popover
v-model:visible="popVisible"
@ -13,27 +14,39 @@
@show="loadReadUsers"
>
<template #reference>
<span class="im-message-read-status">
<span
class="text-12px whitespace-nowrap cursor-pointer text-[var(--el-text-color-secondary)] hover:text-[#409eff]"
>
{{ label }}
</span>
</template>
<el-tabs v-model="activeTab" stretch>
<el-tab-pane :label="`已读(${readMembers.length})`" name="read">
<PagedScroller :items="readMembers" :page-size="20" class="im-message-read-status__scroll">
<PagedScroller :items="readMembers" :page-size="20" class="h-75">
<template #default="{ item }">
<ChatGroupMember :member="(item as GroupMemberLite)" :height="40" :clickable="false" />
<ChatGroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" />
</template>
</PagedScroller>
<div v-if="readMembers.length === 0" class="im-message-read-status__empty"></div>
<div
v-if="readMembers.length === 0"
class="py-5 text-12px text-center text-[var(--el-text-color-disabled)]"
>
暂无已读
</div>
</el-tab-pane>
<el-tab-pane :label="`未读(${unreadMembers.length})`" name="unread">
<PagedScroller :items="unreadMembers" :page-size="20" class="im-message-read-status__scroll">
<PagedScroller :items="unreadMembers" :page-size="20" class="h-75">
<template #default="{ item }">
<ChatGroupMember :member="(item as GroupMemberLite)" :height="40" :clickable="false" />
<ChatGroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" />
</template>
</PagedScroller>
<div v-if="unreadMembers.length === 0" class="im-message-read-status__empty"></div>
<div
v-if="unreadMembers.length === 0"
class="py-5 text-12px text-center text-[var(--el-text-color-disabled)]"
>
全部已读
</div>
</el-tab-pane>
</el-tabs>
</el-popover>
@ -42,84 +55,110 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { getGroupReadUsers } from '@/api/im/message/group'
import { ImGroupReceiptStatus } from '../../../../../utils/constants'
import type { MessageInfo } from '../../../../types'
import { getGroupReadUsers as apiGetGroupReadUsers } from '@/api/im/message/group'
import { CommonStatusEnum } from '@/utils/constants'
import { ImConversationType, ImGroupReceiptStatus } from '../../../../../utils/constants'
import type { Message } from '../../../../types'
import { useConversationStore } from '../../../../store/conversationStore'
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue'
import PagedScroller from '../../../../components/PagedScroller.vue'
defineOptions({ name: 'ImMessageReadStatus' })
const props = defineProps<{
message: MessageInfo
/** 当前群所有成员(第一期外部传入;没有就传空数组,未读列表会空) */
message: Message
// ChatPanel.groupMembers
groupMembers: GroupMemberLite[]
/** 当前群 id */
groupId: string
// loadReadUsers /im/message/group/get-read-users
groupId: number
}>()
const conversationStore = useConversationStore()
// popover show readUserIds
const popVisible = ref(false)
const activeTab = ref<'read' | 'unread'>('read')
const readUserIds = ref<string[]>([])
// " userId " visibleMembers
const readUserIds = ref<number[]>([])
/**
* 标签文案
* - receiptStatus === DONE服务端确认全员已读 直接显示"全部已读"readCount 不再使用
* - readCount > 0显示"N 人已读"
* - 其他readCount = 0 undefined且未到 DONE显示"未读"
*/
const label = computed(() => {
if (props.message.receiptStatus === ImGroupReceiptStatus.DONE) return '全部已读'
const n = props.message.readCount || 0
return n > 0 ? `${n} 人已读` : '未读'
if (props.message.receiptStatus === ImGroupReceiptStatus.DONE) {
return '全部已读'
}
const readCount = props.message.readCount || 0
return readCount > 0 ? `${readCount} 人已读` : '未读'
})
/**
* 这条消息"应该被谁看到"的可见成员集合已读 / 未读两个 tab 共用基底
*
* 三层过滤
* 1. 定向群消息receiverUserIds 非空时只算名单内的成员少了这层未在投递范围里的人
* 会被错算成"未读"getGroupReadUsers 后端已按可见性过滤前端必须配套
* 2. 发送者本人自己发的消息算不算"自己已读"语义模糊索性两个 tab 都不展示
* 3. 已退群status === DISABLE他读没读已经不关心UI 不展示
*/
const visibleMembers = computed<GroupMemberLite[]>(() => {
const receiverUserIds = props.message.receiverUserIds
const isDirected = !!receiverUserIds && receiverUserIds.length > 0
return props.groupMembers.filter(
(member) =>
member.status !== CommonStatusEnum.DISABLE &&
member.userId !== props.message.senderId &&
(!isDirected || receiverUserIds.includes(member.userId))
)
})
/** 已读 = 可见成员 ∩ readUserIds */
const readMembers = computed(() =>
props.groupMembers.filter(
(m) =>
!m.quit &&
String(m.userId) !== String(props.message.sendId) &&
readUserIds.value.includes(String(m.userId))
)
visibleMembers.value.filter((member) => readUserIds.value.includes(member.userId))
)
/** 未读 = 可见成员 readUserIds */
const unreadMembers = computed(() =>
props.groupMembers.filter(
(m) =>
!m.quit &&
String(m.userId) !== String(props.message.sendId) &&
!readUserIds.value.includes(String(m.userId))
)
visibleMembers.value.filter((member) => !readUserIds.value.includes(member.userId))
)
/**
* 拉取已读用户 id 列表
*
* 触发el-popover @show按需懒加载避免每条群消息都预拉
* 跳过本地占位消息id = 0还没拿到服务端 id后端没法按 messageId
* 失败仅控制台告警readUserIds 保持空数组 label readCount 兜底不阻塞 UI
*
* 拉到名单后顺手把 readCount / receiptStatus 回写到 conversationStore popover 外面的
* label 也跟着走最新数离线 / 漏收 RECEIPT 事件时本地 readCount 会偏旧弹层里看到"已读 5"
* 但外面仍是"未读"或旧人数这里以服务端返回为准矫正回去
*/
async function loadReadUsers() {
if (!props.message.id) return
if (!props.message.id) {
return
}
try {
const res = await getGroupReadUsers({
const userIds = await apiGetGroupReadUsers({
groupId: props.groupId,
messageId: props.message.id
})
readUserIds.value = (res || []).map(String)
} catch (e) {
console.error('[IM] 拉取群已读列表失败:', e)
readUserIds.value = userIds || []
const readCount = readUserIds.value.length
// flip DONE label ""
// readCountreceiptStatus PENDING / READING
const allRead = readCount > 0 && readCount >= visibleMembers.value.length
conversationStore.applyReadReceipt({
conversationType: ImConversationType.GROUP,
targetId: props.groupId,
groupMessageId: props.message.id,
readCount,
receiptStatus: allRead ? ImGroupReceiptStatus.DONE : undefined
})
} catch (error) {
console.error('[IM] 拉取群已读列表失败:', error)
}
}
</script>
<style scoped>
.im-message-read-status {
font-size: 12px;
color: #909399;
white-space: nowrap;
cursor: pointer;
}
.im-message-read-status:hover {
color: #409eff;
}
.im-message-read-status__scroll {
height: 300px;
}
.im-message-read-status__empty {
padding: 20px 0;
font-size: 12px;
color: #c0c4cc;
text-align: center;
}
</style>