admin-vue3/src/views/im/home/pages/conversation/components/message/MessagePanel.vue

423 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="flex flex-1 flex-col min-w-0 bg-[var(--el-fill-color-light)]">
<template v-if="conversationStore.activeConversation">
<!-- + -->
<div
class="flex items-center justify-between h-14 px-5 bg-[var(--el-fill-color-light)] border-b border-[var(--el-border-color-light)]"
>
<span class="text-base font-medium text-[var(--el-text-color-primary)]">
{{ conversationStore.activeConversation?.name || '' }}
</span>
<div class="flex gap-3 items-center">
<!-- 聊天历史从输入区底部工具栏挪到顶部右上角对齐微信 PC点击弹窗承接历史消息 -->
<el-tooltip content="聊天历史" placement="bottom">
<Icon
icon="ant-design:profile-outlined"
:size="20"
class="message-panel__header-icon cursor-pointer"
@click="historyVisible = true"
/>
</el-tooltip>
<!-- 信息抽屉入口群聊 / 私聊统一用 3 点图标对齐微信 PC
sideVisible 在群 / 私聊两个抽屉之间共用一个 ref v-if-else 决定挂哪个 -->
<el-tooltip :content="isGroup ? '群聊信息' : '聊天信息'" placement="bottom">
<Icon
icon="ant-design:more-outlined"
:size="20"
class="message-panel__header-icon cursor-pointer"
@click="toggleSide"
/>
</el-tooltip>
</div>
</div>
<!-- 中间消息列表 -->
<div
ref="listRef"
class="relative flex-1 py-2 overflow-y-auto bg-[var(--el-bg-color-page)]"
@scroll="handleScroll"
>
<div
v-if="messages.length === 0"
class="flex items-center justify-center h-full text-sm text-[var(--el-text-color-secondary)]"
>
暂无消息
</div>
<!-- data-message-id 给 MessageHistory "定位到聊天位置" 用:父级通过 querySelector
找到这层 wrapperscrollIntoView + 加高亮 classid=0 的本地占位消息跳过 -->
<div
v-for="msg in messages"
:key="msg.id || msg.clientMessageId"
:data-message-id="msg.id || ''"
class="message-panel__message-anchor"
>
<MessageItem :message="msg" />
</div>
<!-- 回到底部浮动按钮(滚动不在底部时显示) -->
<transition name="message-panel__jump-fade">
<div
v-if="showJumpToBottom"
class="message-panel__jump-bottom sticky bottom-3 left-1/2 inline-flex gap-1.5 items-center w-fit mx-auto px-3.5 py-1.5 text-xs text-[#409eff] bg-[var(--el-bg-color-overlay)] rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.12)] cursor-pointer hover:text-white hover:bg-[#409eff]"
@click="scrollToBottom(true)"
>
<Icon icon="ant-design:down-outlined" :size="14" />
<span v-if="newMessageCount > 0" class="font-medium">
{{ newMessageCount > 99 ? '99+' : newMessageCount }} 条新消息
</span>
<span v-else></span>
</div>
</transition>
</div>
<!--
:key A B editor / mention range / pendingAtUserIds
稿 @ -->
<!-- TODO @AI切换时之前的要被保留 -->
<!-- TODO @AI切换时用户如果有输入信息需要把 lastContent 变成输入信息 -->
<MessageInput :key="messageInputKey" />
<!-- 右侧信息抽屉群聊 / 私聊各自一份 -->
<ConversationGroupSide
v-if="isGroup"
v-model="sideVisible"
:group="groupInfo"
:conversation="conversationStore.activeConversation"
:members="groupMembers"
:friends="friends"
@reload="reloadGroupData"
@open-history="historyVisible = true"
/>
<ConversationPrivateSide
v-else
v-model="sideVisible"
:conversation="conversationStore.activeConversation"
:friend="privateFriend"
:friends="friends"
@open-history="historyVisible = true"
/>
<!-- 历史消息抽屉 -->
<MessageHistory v-model="historyVisible" @locate="handleLocate" />
</template>
<div
v-else
class="flex items-center justify-center h-full text-sm text-[var(--el-text-color-secondary)]"
>
<span>选择一个会话开始聊天</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, nextTick, computed } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { getMemberDisplayName } from '@/views/im/utils/user'
import { useGroupStore } from '../../../../store/groupStore'
import { ImConversationType } from '@/views/im/utils/constants'
import { getConversationKey } from '@/views/im/utils/conversation'
import { CommonStatusEnum } from '@/utils/constants'
import MessageItem from './MessageItem.vue'
import MessageInput from '../input/MessageInput.vue'
import MessageHistory from './MessageHistory.vue'
import ConversationGroupSide from '../conversation/ConversationGroupSide.vue'
import ConversationPrivateSide from '../conversation/ConversationPrivateSide.vue'
import type { FriendLite, GroupLite } from '../../../../types'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
defineOptions({ name: 'ImMessagePanel' })
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const listRef = ref<HTMLElement>()
const messages = computed(() => conversationStore.getActiveMessages)
const isGroup = computed(
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
)
/**
* MessageInput 的 :key —— 切群时强制 unmount + remount让 editor / mention range /
* 上一会话草稿全部归零;用 fallback 'none' 避开 activeConversation 短暂为 null 的窗口
*/
const messageInputKey = computed(() => {
const conv = conversationStore.activeConversation
return conv ? getConversationKey(conv) : 'none'
})
/** "是否停留在底部"的阈值:距离底部 < 80px 视为底部 */
const BOTTOM_THRESHOLD = 80
/** 当前是否已不在底部(显示"回到底部"按钮) */
const showJumpToBottom = ref(false)
/** 不在底部期间累计的新消息数 */
const newMessageCount = ref(0)
/**
* 当前激活的群详情:优先 groupStore带详细字段未加载完时用 activeConversation 兜底
*
* groupStore 是按需懒加载的,初次进群时 ensureGroupData 触发后才会有完整数据;
* 兜底字段name / avatar保证 header 不会"闪空"notice / ownerId / memberCount
* 必须等 store 就位才有值(这些字段在 conversation 里没有)
*/
const groupInfo = computed<
(GroupLite & { notice?: string; remarkNickName?: string; ownerId?: number }) | undefined
>(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return undefined
}
const group = groupStore.getGroup(conversation.targetId)
return {
id: conversation.targetId,
name: group?.name || conversation.name,
showGroupName: group?.name || conversation.name,
showImage: group?.avatar || conversation.avatar,
notice: group?.notice,
ownerId: group?.ownerUserId,
memberCount: group?.memberCount
}
})
// 群成员列表:直接取 groupStore 缓存map 成 GroupMemberLite 给下游消费(@-mention / 邀请等)
const groupMembers = computed<GroupMemberLite[]>(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return []
}
const group = groupStore.getGroup(conversation.targetId)
return (group?.members || []).map((member) => {
// 显示名走「好友备注 > 群备注 > 真实昵称」三级;头像走 nickname 保稳定
const friend = friendStore.getFriend(member.userId)
return {
userId: member.userId,
showName: getMemberDisplayName(member, friend),
nickname: member.nickname,
avatar: member.avatar,
status: member.status
}
})
})
/** 好友列表:群侧用于"邀请入群",私聊侧用于"+创建群",统一从 friendStore 映射成 FriendLite 窄接口 */
const friends = computed<FriendLite[]>(() =>
friendStore.getActiveFriends.map((friend) => ({
id: friend.friendUserId,
nickname: friend.nickname,
avatar: friend.avatar,
deleted: friend.status === CommonStatusEnum.DISABLE
}))
)
/** 切换到群会话时同步群信息 + 成员;各自 fire-and-forget + catch任何一项失败不牵连其它 */
async function ensureGroupData(groupId: number) {
// 远程异步拉群信息(群名 / 公告 / 群主等元数据)
groupStore.fetchGroupInfo(groupId).catch((error) => {
console.warn('[IM MessagePanel] fetchGroupInfo 失败', { groupId }, error)
})
// 先从 IDB 同步加载群成员,让首帧立即出成员名 / 头像
await groupStore.loadGroupMembers(groupId).catch((error) => {
console.warn('[IM MessagePanel] loadGroupMembers 失败', { groupId }, error)
return null
})
// 再从远程异步拉成员,强刷以跳过 in-memory 短路,每次进群都能拿到最新成员状态
groupStore.fetchGroupMembers(groupId, true).catch((error) => {
console.warn('[IM MessagePanel] fetchGroupMembers 失败', { groupId }, error)
})
}
/** 群信息抽屉里点"刷新":强拉一次最新群元数据 + 群成员 */
function reloadGroupData() {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return
}
groupStore.fetchGroupInfo(conversation.targetId)
groupStore.fetchGroupMembers(conversation.targetId, true)
}
const historyVisible = ref(false)
const sideVisible = ref(false) // 信息抽屉开关:群聊 / 私聊共用一个 ref
/** 信息抽屉的 toggle跟 header 上 3 点图标按钮共用 */
function toggleSide() {
sideVisible.value = !sideVisible.value
}
/** 当前私聊对应的好友(抽屉头部展示用) */
const privateFriend = computed(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.PRIVATE) {
return undefined
}
return friendStore.getFriend(conversation.targetId)
})
/** 计算距离底部的像素 */
function distanceFromBottom(): number {
const el = listRef.value
if (!el) {
return 0
}
return el.scrollHeight - el.scrollTop - el.clientHeight
}
/**
* 消息列表滚动事件:刷新"是否在底部"状态
* - 在底部:隐藏"回到底部"浮窗 + 清掉"未读新消息"计数
* - 不在底部:显示"回到底部"浮窗,新消息会累计到 newMessageCount
*/
function handleScroll() {
const dist = distanceFromBottom()
const atBottom = dist <= BOTTOM_THRESHOLD
showJumpToBottom.value = !atBottom
if (atBottom) {
newMessageCount.value = 0
}
}
/**
* 滚到底部:切会话 / 收到新消息(且当前在底部)/ 用户主动点"回到底部" 都走这里
*
* 包 nextTick 是为了等 v-for 把新消息真正渲染进 DOM 后再算 scrollHeight
* 否则可能滚到的还是旧高度差最后一条的位置。smooth=true 走平滑动画,
* 适合用户主动点击;初始 / 自动滚动用 auto避免用户感知到动画拖拽
*/
function scrollToBottom(smooth = false) {
nextTick(() => {
if (!listRef.value) {
return
}
listRef.value.scrollTo({
top: listRef.value.scrollHeight,
behavior: smooth ? 'smooth' : 'auto'
})
newMessageCount.value = 0
showJumpToBottom.value = false
})
}
/**
* 定位到聊天位置MessageHistory 行上"定位"按钮触发
*
* 1. 先关掉历史弹窗(避免 scroll 时遮挡 + dialog 关闭后让聊天面板拿回焦点)
* 2. nextTick 等弹窗 leave 动画 / 列表渲染稳定后再查 DOM
* 3. 按 data-message-id 找 wrapperscrollIntoView({ block: center }) 让消息落到视口中部
* 4. 加 --highlight class 短暂高亮,提示用户"就是这条"
*/
async function handleLocate(messageId: number) {
if (!messageId) {
return
}
await nextTick()
if (!listRef.value) {
return
}
const target = listRef.value.querySelector<HTMLElement>(`[data-message-id="${messageId}"]`)
if (!target) {
return
}
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
target.classList.add('message-panel__message-anchor--highlight')
setTimeout(() => {
target.classList.remove('message-panel__message-anchor--highlight')
}, 1600)
}
/**
* 消息数量变化时的滚动跟进策略
* - 当前在底部 → 自动滚到新底部(用户在"实时跟读",体验上跟微信一致)
* - 不在底部 → 累计 newMessageCount让 sticky 浮窗显示"X 条新消息",让用户主动点
*/
watch(
() => messages.value.length,
(newLen, oldLen) => {
// 仅处理新增delta > 0删除 / 撤回让 length 减少时不动滚动状态
const delta = (newLen || 0) - (oldLen || 0)
if (delta <= 0) {
return
}
// 用 BOTTOM_THRESHOLD80px做容差用户稍微往上翻几行就视作"不在底部"
// 否则一直 auto-scroll 会把人正在读的内容顶走,体验糟糕
const dist = distanceFromBottom()
if (dist <= BOTTOM_THRESHOLD) {
scrollToBottom()
} else {
newMessageCount.value += delta
showJumpToBottom.value = true
}
}
)
/**
* 切换会话:清空"在不在底部"相关状态、强制滚到底部、群会话预拉资料
*
* immediate:true 让首次进入页面就能正确初始化(无需等到第一次切换)
*/
watch(
() => conversationStore.activeConversation?.targetId,
(targetId) => {
// 切群时上一会话的"未读累计 + 浮窗显示"必须清掉,否则会带到新会话里看起来很突兀
newMessageCount.value = 0
showJumpToBottom.value = false
scrollToBottom()
// 仅群聊预拉详情 / 成员(私聊对端在首屏 fetchFriends 时就拉了)
if (targetId && conversationStore.activeConversation?.type === ImConversationType.GROUP) {
ensureGroupData(targetId)
}
},
{ immediate: true }
)
</script>
<style scoped>
/* el-icon 全局规则 .el-icon{color:var(--color,inherit)} 优先级胜过 UnoCSS这里用 :deep + !important 兜底;
颜色直接引用 Element Plus 主题变量,暗色模式自动切到更亮的灰 */
.message-panel__header-icon,
.message-panel__header-icon :deep(svg) {
color: var(--el-text-color-regular) !important;
fill: currentColor !important;
transition: color 0.15s;
}
.message-panel__header-icon:hover,
.message-panel__header-icon:hover :deep(svg) {
color: var(--el-color-primary) !important;
}
/* sticky + translate 居中fit-content 宽度不会撑满transform 做水平 -50% 偏移;
UnoCSS 表达 transform+transition 多 value 不太方便,这里用最小的 scoped CSS 承接 */
.message-panel__jump-bottom {
transform: translateX(-50%);
transition:
opacity 0.2s,
transform 0.2s;
}
/* MessageHistory "定位" 跳过来时短暂高亮1.6s 后由 JS 移除 class配合 transition 缓出黄底 */
.message-panel__message-anchor {
transition: background-color 0.6s ease;
}
.message-panel__message-anchor--highlight {
background-color: var(--el-color-warning-light-9);
}
/* 回到底部按钮的 Vue transition 钩子类名 */
.message-panel__jump-fade-enter-active,
.message-panel__jump-fade-leave-active {
transition:
opacity 0.2s,
transform 0.2s;
}
.message-panel__jump-fade-enter-from,
.message-panel__jump-fade-leave-to {
opacity: 0;
transform: translate(-50%, 20px);
}
</style>