✨ feat(im): 新增 MessageReadStatus.vue
parent
bfa267120a
commit
8847cdb79f
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue