✨ feat(im): 新增 MessageReadStatus.vue
parent
bfa267120a
commit
8847cdb79f
|
|
@ -1,9 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<!--
|
<!--
|
||||||
群消息已读状态(对应 boxim chat/ChatGroupReaded.vue)
|
群消息已读状态
|
||||||
- 标签形态:展示「N 已读」或「全部已读」;点击弹出 tab 列出具体成员
|
- 标签形态:展示「N 已读」或「全部已读」;点击弹出 popover 用 tab 列出已读 / 未读成员
|
||||||
- 仅在群聊、自己发送、已送达的消息下使用
|
- 渲染条件:仅在群聊、自己发送、receiptStatus !== NO_RECEIPT 时挂出(由 MessageItem 把关)
|
||||||
- 依赖 getGroupReadUsers API 拉已读人列表;未读列表由群成员减去已读得出
|
- 已读名单按需拉:popover 触发 show 才打 /im/message/group/get-read-users,避免每条群消息都预拉
|
||||||
|
- 未读名单 = 当前群成员 − 自己 − 已退群 − 已读集合(前端聚合,后端只回已读 userId)
|
||||||
-->
|
-->
|
||||||
<el-popover
|
<el-popover
|
||||||
v-model:visible="popVisible"
|
v-model:visible="popVisible"
|
||||||
|
|
@ -13,27 +14,39 @@
|
||||||
@show="loadReadUsers"
|
@show="loadReadUsers"
|
||||||
>
|
>
|
||||||
<template #reference>
|
<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 }}
|
{{ label }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-tabs v-model="activeTab" stretch>
|
<el-tabs v-model="activeTab" stretch>
|
||||||
<el-tab-pane :label="`已读(${readMembers.length})`" name="read">
|
<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 }">
|
<template #default="{ item }">
|
||||||
<ChatGroupMember :member="(item as GroupMemberLite)" :height="40" :clickable="false" />
|
<ChatGroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" />
|
||||||
</template>
|
</template>
|
||||||
</PagedScroller>
|
</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>
|
||||||
<el-tab-pane :label="`未读(${unreadMembers.length})`" name="unread">
|
<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 }">
|
<template #default="{ item }">
|
||||||
<ChatGroupMember :member="(item as GroupMemberLite)" :height="40" :clickable="false" />
|
<ChatGroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" />
|
||||||
</template>
|
</template>
|
||||||
</PagedScroller>
|
</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-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</el-popover>
|
</el-popover>
|
||||||
|
|
@ -42,84 +55,110 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import { getGroupReadUsers } from '@/api/im/message/group'
|
import { getGroupReadUsers as apiGetGroupReadUsers } from '@/api/im/message/group'
|
||||||
import { ImGroupReceiptStatus } from '../../../../../utils/constants'
|
import { CommonStatusEnum } from '@/utils/constants'
|
||||||
import type { MessageInfo } from '../../../../types'
|
import { ImConversationType, ImGroupReceiptStatus } from '../../../../../utils/constants'
|
||||||
|
import type { Message } from '../../../../types'
|
||||||
|
import { useConversationStore } from '../../../../store/conversationStore'
|
||||||
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue'
|
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue'
|
||||||
import PagedScroller from '../../../../components/PagedScroller.vue'
|
import PagedScroller from '../../../../components/PagedScroller.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'ImMessageReadStatus' })
|
defineOptions({ name: 'ImMessageReadStatus' })
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
message: MessageInfo
|
message: Message
|
||||||
/** 当前群所有成员(第一期外部传入;没有就传空数组,未读列表会空) */
|
// 当前群所有成员(外部 ChatPanel.groupMembers 传入;没有就传空数组,未读列表会变空但不报错)
|
||||||
groupMembers: GroupMemberLite[]
|
groupMembers: GroupMemberLite[]
|
||||||
/** 当前群 id */
|
// 当前群编号;供 loadReadUsers 作为 /im/message/group/get-read-users 的入参
|
||||||
groupId: string
|
groupId: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const conversationStore = useConversationStore()
|
||||||
|
|
||||||
|
// popover 开关:show 时拉已读名单,关闭后保留 readUserIds 缓存(重开同一条消息不再请求)
|
||||||
const popVisible = ref(false)
|
const popVisible = ref(false)
|
||||||
const activeTab = ref<'read' | 'unread'>('read')
|
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(() => {
|
const label = computed(() => {
|
||||||
if (props.message.receiptStatus === ImGroupReceiptStatus.DONE) return '全部已读'
|
if (props.message.receiptStatus === ImGroupReceiptStatus.DONE) {
|
||||||
const n = props.message.readCount || 0
|
return '全部已读'
|
||||||
return n > 0 ? `${n} 人已读` : '未读'
|
}
|
||||||
|
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(() =>
|
const readMembers = computed(() =>
|
||||||
props.groupMembers.filter(
|
visibleMembers.value.filter((member) => readUserIds.value.includes(member.userId))
|
||||||
(m) =>
|
|
||||||
!m.quit &&
|
|
||||||
String(m.userId) !== String(props.message.sendId) &&
|
|
||||||
readUserIds.value.includes(String(m.userId))
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 未读 = 可见成员 − readUserIds */
|
||||||
const unreadMembers = computed(() =>
|
const unreadMembers = computed(() =>
|
||||||
props.groupMembers.filter(
|
visibleMembers.value.filter((member) => !readUserIds.value.includes(member.userId))
|
||||||
(m) =>
|
|
||||||
!m.quit &&
|
|
||||||
String(m.userId) !== String(props.message.sendId) &&
|
|
||||||
!readUserIds.value.includes(String(m.userId))
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取已读用户 id 列表
|
||||||
|
*
|
||||||
|
* 触发:el-popover @show(按需懒加载,避免每条群消息都预拉)
|
||||||
|
* 跳过:本地占位消息(id = 0,还没拿到服务端 id),后端没法按 messageId 查
|
||||||
|
* 失败:仅控制台告警,readUserIds 保持空数组 → label 走 readCount 兜底,不阻塞 UI
|
||||||
|
*
|
||||||
|
* 拉到名单后顺手把 readCount / receiptStatus 回写到 conversationStore,让 popover 外面的
|
||||||
|
* label 也跟着走最新数:离线 / 漏收 RECEIPT 事件时本地 readCount 会偏旧,弹层里看到"已读 5"
|
||||||
|
* 但外面仍是"未读"或旧人数;这里以服务端返回为准矫正回去
|
||||||
|
*/
|
||||||
async function loadReadUsers() {
|
async function loadReadUsers() {
|
||||||
if (!props.message.id) return
|
if (!props.message.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await getGroupReadUsers({
|
const userIds = await apiGetGroupReadUsers({
|
||||||
groupId: props.groupId,
|
groupId: props.groupId,
|
||||||
messageId: props.message.id
|
messageId: props.message.id
|
||||||
})
|
})
|
||||||
readUserIds.value = (res || []).map(String)
|
readUserIds.value = userIds || []
|
||||||
} catch (e) {
|
const readCount = readUserIds.value.length
|
||||||
console.error('[IM] 拉取群已读列表失败:', e)
|
// 全可见成员都已读 → 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>
|
</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