✨ feat(im): 新增 MessageReadStatus.vue

pull/881/head
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 直接走"全部已读"短路;
// 否则只更新 readCount,receiptStatus 维持不变(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>