815 lines
32 KiB
Vue
815 lines
32 KiB
Vue
<template>
|
||
<div class="flex flex-1 flex-col min-w-0 bg-[var(--el-fill-color-light)]">
|
||
<template v-if="conversationStore.activeConversation">
|
||
<!-- 顶部 header:第一行群名 + 右侧图标,第二行嵌入置顶气泡(仅群聊 + 有置顶);border 走 scoped CSS -->
|
||
<div class="message-panel__header flex flex-col bg-[var(--el-fill-color-light)]">
|
||
<div class="flex items-center justify-between h-14 px-5">
|
||
<span class="flex flex-col min-w-0">
|
||
<span class="flex items-baseline gap-1.5 min-w-0">
|
||
<span
|
||
class="overflow-hidden text-base font-medium truncate text-[var(--el-text-color-primary)]"
|
||
>
|
||
{{ conversationStore.activeConversation?.name || '' }}
|
||
</span>
|
||
<span
|
||
v-if="isGroup && headerMemberCount > 0"
|
||
class="flex-shrink-0 text-sm text-[var(--el-text-color-secondary)]"
|
||
>
|
||
({{ headerMemberCount }})
|
||
</span>
|
||
</span>
|
||
<!-- 副标题:备注 ≠ 群名时展示原群名,提示用户当前看到的主名是自己设的备注 -->
|
||
<span
|
||
v-if="headerSubtitle"
|
||
class="overflow-hidden text-xs truncate text-[var(--el-text-color-secondary)]"
|
||
>
|
||
{{ headerSubtitle }}
|
||
</span>
|
||
</span>
|
||
<div class="flex gap-3 items-center">
|
||
<!-- 聊天历史 -->
|
||
<el-tooltip content="聊天历史" placement="bottom">
|
||
<Icon
|
||
icon="ep:chat-dot-round"
|
||
:size="20"
|
||
class="message-panel__header-icon cursor-pointer"
|
||
@click="historyDialogRef?.open()"
|
||
/>
|
||
</el-tooltip>
|
||
<!-- 通话入口:私聊弹「语音 / 视频」popover;群聊直接进选人弹窗 -->
|
||
<el-popover
|
||
v-if="isPrivate"
|
||
v-model:visible="callPopoverVisible"
|
||
placement="bottom-end"
|
||
:width="140"
|
||
trigger="click"
|
||
popper-class="message-panel__call-popover"
|
||
>
|
||
<template #reference>
|
||
<Icon
|
||
icon="ant-design:phone-outlined"
|
||
:size="20"
|
||
class="message-panel__header-icon cursor-pointer"
|
||
/>
|
||
</template>
|
||
<div class="message-panel__call-menu">
|
||
<div
|
||
class="message-panel__call-menu-item"
|
||
@click="startPrivateCall(ImRtcCallMediaType.VOICE)"
|
||
>
|
||
<Icon icon="ant-design:phone-outlined" :size="16" />
|
||
<span>语音通话</span>
|
||
</div>
|
||
<div
|
||
class="message-panel__call-menu-item"
|
||
@click="startPrivateCall(ImRtcCallMediaType.VIDEO)"
|
||
>
|
||
<Icon icon="ant-design:video-camera-outlined" :size="16" />
|
||
<span>视频通话</span>
|
||
</div>
|
||
</div>
|
||
</el-popover>
|
||
<el-tooltip v-else content="通话" placement="bottom">
|
||
<Icon
|
||
icon="ant-design:phone-outlined"
|
||
:size="20"
|
||
class="message-panel__header-icon cursor-pointer"
|
||
@click="handleGroupCall"
|
||
/>
|
||
</el-tooltip>
|
||
<!-- 信息抽屉入口 -->
|
||
<el-tooltip :content="isGroup ? '群聊信息' : '聊天信息'" placement="bottom">
|
||
<Icon
|
||
icon="ant-design:ellipsis-outlined"
|
||
:size="20"
|
||
class="message-panel__header-icon cursor-pointer"
|
||
@click="toggleSide"
|
||
/>
|
||
</el-tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 群通话胶囊条:仅群聊 + 该群有活跃通话时显示;点击展开看成员 + 加入按钮 -->
|
||
<RtcGroupCallBanner
|
||
v-if="isGroup && conversationStore.activeConversation"
|
||
:group-id="conversationStore.activeConversation.targetId"
|
||
/>
|
||
|
||
<!-- 群置顶消息:第二行嵌入 header;仅群聊 + 有置顶时显示 -->
|
||
<GroupPinnedMessage
|
||
v-if="isGroup && conversationStore.activeConversation"
|
||
:group-id="conversationStore.activeConversation.targetId"
|
||
@locate="handleLocate"
|
||
/>
|
||
<!-- 群顶部「待处理加群申请」横幅:仅群聊 + owner / admin + count > 0 时显示 -->
|
||
<GroupRequestPending
|
||
v-if="isGroup && conversationStore.activeConversation"
|
||
:group-id="conversationStore.activeConversation.targetId"
|
||
/>
|
||
<!-- 私聊:对方不再是有效好友(我删了对方 / 从未加过;单边设计下「被对方删除」本端 friendStore 不更新故不会触发);胶囊嵌在 header 内(跟群置顶同级),点击弹 UserInfoCard -->
|
||
<div v-if="showNotFriendBanner" class="message-panel__not-friend-container">
|
||
<div class="message-panel__not-friend" @click="handleNotFriendClick">
|
||
<span class="message-panel__not-friend-icon">
|
||
<Icon icon="ant-design:user-outlined" :size="11" />
|
||
</span>
|
||
<span>对方还不是你的朋友</span>
|
||
<Icon icon="ep:arrow-right" :size="12" class="text-[var(--el-text-color-secondary)]" />
|
||
</div>
|
||
</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
|
||
找到这层 wrapper,scrollIntoView + 加高亮 class;id=0 的本地占位消息跳过 -->
|
||
<div
|
||
v-for="(msg, index) in messages"
|
||
:key="msg.id || msg.clientMessageId"
|
||
:data-message-id="msg.id || ''"
|
||
class="message-panel__message-anchor"
|
||
>
|
||
<MessageItem
|
||
:message="msg"
|
||
:prev-message="messages[index - 1]"
|
||
@locate="handleLocate"
|
||
@mute="handleMuteMember"
|
||
@reload="reloadGroupData"
|
||
/>
|
||
</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>
|
||
|
||
<!-- 底部:输入框常驻;多选模式底栏作为浮层盖在上面,保持下方输入框尺寸不变 -->
|
||
<div class="relative">
|
||
<MessageInput />
|
||
<MessageMultiSelectBar v-if="multiSelect.state.active" class="absolute inset-0 z-10" />
|
||
</div>
|
||
|
||
<!-- 右侧信息抽屉:群聊 / 私聊各自一份 -->
|
||
<ConversationGroupSide
|
||
v-if="isGroup"
|
||
v-model="sideVisible"
|
||
:group="groupInfo"
|
||
:conversation="conversationStore.activeConversation"
|
||
:members="groupMembers"
|
||
@reload="reloadGroupData"
|
||
@open-history="historyDialogRef?.open()"
|
||
/>
|
||
<ConversationPrivateSide
|
||
v-else
|
||
v-model="sideVisible"
|
||
:conversation="conversationStore.activeConversation"
|
||
:friend="privateFriend"
|
||
@open-history="historyDialogRef?.open()"
|
||
/>
|
||
|
||
<!-- 历史消息抽屉 -->
|
||
<MessageHistory ref="historyDialogRef" @locate="handleLocate" />
|
||
|
||
<!-- 转发弹窗 / 合并消息详情:在 MessagePanel 子树内挂载,子组件通过 inject 触发 -->
|
||
<MessageForwardDialog ref="forwardDialogRef" />
|
||
<MessageMergeDetailDialog ref="mergeDetailDialogRef" />
|
||
|
||
<!-- 禁言时长选择弹窗 -->
|
||
<GroupMuteMemberDialog ref="muteMemberDialogRef" @success="reloadGroupData" />
|
||
|
||
<!-- 群通话成员选择弹窗 -->
|
||
<RtcCallMemberPickerDialog ref="callMemberPickerRef" @success="onCallMemberPicked" />
|
||
</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, provide } from 'vue'
|
||
import Icon from '@/components/Icon/src/Icon.vue'
|
||
import { useMessage } from '@/hooks/web/useMessage'
|
||
|
||
import { useConversationStore } from '../../../../store/conversationStore'
|
||
import { useFriendStore } from '../../../../store/friendStore'
|
||
import { useImUiStore } from '../../../../store/uiStore'
|
||
import { getMemberDisplayName } from '@/views/im/utils/user'
|
||
import { useGroupStore } from '../../../../store/groupStore'
|
||
import MessageItem from './MessageItem.vue'
|
||
import MessageInput from '../input/MessageInput.vue'
|
||
import MessageMultiSelectBar from '../input/MessageMultiSelectBar.vue'
|
||
import MessageForwardDialog from './forward/MessageForwardDialog.vue'
|
||
import MessageMergeDetailDialog from './forward/MessageMergeDetailDialog.vue'
|
||
import {
|
||
IM_FORWARD_DIALOG_KEY,
|
||
IM_MERGE_DETAIL_DIALOG_KEY,
|
||
IM_RTC_REDIAL_KEY
|
||
} from './forward/keys'
|
||
import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect'
|
||
import { useVoicePlayer } from '../../../../composables/useVoicePlayer'
|
||
import MessageHistory from './MessageHistory.vue'
|
||
import ConversationGroupSide from '../conversation/ConversationGroupSide.vue'
|
||
import GroupPinnedMessage from './GroupPinnedMessage.vue'
|
||
import GroupRequestPending from './GroupRequestPending.vue'
|
||
import ConversationPrivateSide from '../conversation/ConversationPrivateSide.vue'
|
||
import type { GroupLite } from '../../../../types'
|
||
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
||
import GroupMuteMemberDialog from '../../../../components/group/GroupMuteMemberDialog.vue'
|
||
import RtcCallMemberPickerDialog from '../../../../components/rtc/RtcCallMemberPickerDialog.vue'
|
||
import RtcGroupCallBanner from '../../../../components/rtc/RtcGroupCallBanner.vue'
|
||
import { createCall } from '@/api/im/rtc'
|
||
import { ImRtcCallMediaType, ImRtcCallStatus, ImConversationType } from '@/views/im/utils/constants'
|
||
import { resolveCallEndReasonText } from '@/views/im/utils/message'
|
||
import { useRtcStore } from '../../../../store/rtcStore'
|
||
|
||
defineOptions({ name: 'ImMessagePanel' })
|
||
|
||
const conversationStore = useConversationStore()
|
||
const friendStore = useFriendStore()
|
||
const uiStore = useImUiStore()
|
||
const groupStore = useGroupStore()
|
||
const message = useMessage()
|
||
const rtcStore = useRtcStore()
|
||
const listRef = ref<HTMLElement>()
|
||
|
||
// ==================== 转发 / 合并消息详情:本地 dialog 浮层 ====================
|
||
// MessageItem / MessageMultiSelectBar / MessageHistory 通过 inject 触发;不挂全局 store
|
||
|
||
const forwardDialogRef = ref<InstanceType<typeof MessageForwardDialog>>()
|
||
const mergeDetailDialogRef = ref<InstanceType<typeof MessageMergeDetailDialog>>()
|
||
|
||
provide(IM_FORWARD_DIALOG_KEY, (opts) => forwardDialogRef.value?.open(opts))
|
||
provide(IM_MERGE_DETAIL_DIALOG_KEY, (content) => mergeDetailDialogRef.value?.open(content))
|
||
provide(IM_RTC_REDIAL_KEY, (mediaType: number) => {
|
||
if (isPrivate.value) {
|
||
void startPrivateCall(mediaType)
|
||
}
|
||
}) // 私聊 RTC_CALL_END 气泡点击重拨;MessageItem 注入后调用
|
||
|
||
// ==================== 多选模式 ====================
|
||
// 模块级单例 state(composable);本组件仅做切会话退出 + template 显隐判定
|
||
|
||
const multiSelect = useMessageMultiSelect()
|
||
const voicePlayer = useVoicePlayer()
|
||
|
||
/** 切会话退出多选 + 停语音;避免上一会话的勾选 / 播放态泄漏到新会话(type+targetId 一起监听,私聊与群聊 id 同号时也能触发) */
|
||
watch(
|
||
() => [
|
||
conversationStore.activeConversation?.type,
|
||
conversationStore.activeConversation?.targetId
|
||
],
|
||
() => {
|
||
multiSelect.exit()
|
||
voicePlayer.stop()
|
||
}
|
||
)
|
||
|
||
const messages = computed(() => conversationStore.getActiveMessages)
|
||
const isGroup = computed(
|
||
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
|
||
)
|
||
const isPrivate = computed(
|
||
() => conversationStore.activeConversation?.type === ImConversationType.PRIVATE
|
||
)
|
||
|
||
/** 私聊会话且对端不是有效好友(本端 friend 记录缺失或 DISABLE);单边删除语义下「被对方删除」不触发本端横幅 */
|
||
const showNotFriendBanner = computed(() => {
|
||
const conversation = conversationStore.activeConversation
|
||
if (!conversation || conversation.type !== ImConversationType.PRIVATE) {
|
||
return false
|
||
}
|
||
return !friendStore.isFriend(conversation.targetId)
|
||
})
|
||
|
||
/** 点击「对方还不是你的朋友」胶囊:打开 UserInfoCard,引导用户重新添加 */
|
||
function handleNotFriendClick(event: MouseEvent) {
|
||
const conversation = conversationStore.activeConversation
|
||
if (!conversation) {
|
||
return
|
||
}
|
||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||
uiStore.openUserInfoCard(
|
||
{
|
||
id: conversation.targetId,
|
||
nickname: conversation.name,
|
||
avatar: conversation.avatar
|
||
},
|
||
{ x: rect.left, y: rect.bottom + 4 }
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 群聊 header 显示的人数:优先 groupStore.memberCount(无需等成员列表),无值再回退 members.length
|
||
*
|
||
* 之所以不直接用 groupMembers.value.length:成员列表是按需懒加载的,刚切到群时未加载完,
|
||
* 而 groupInfo.memberCount 跟群信息一起来,能更早显示人数避免"先空再蹦"
|
||
*/
|
||
const headerMemberCount = computed(() => {
|
||
const conversation = conversationStore.activeConversation
|
||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||
return 0
|
||
}
|
||
const group = groupStore.getGroup(conversation.targetId)
|
||
return group?.memberCount ?? group?.members?.length ?? 0
|
||
})
|
||
|
||
/** 顶部副标题:仅当群备注 ≠ 原群名时显示原群名(对齐微信 PC 双行 header) */
|
||
const headerSubtitle = computed(() => {
|
||
const remark = groupInfo.value?.groupRemark
|
||
const name = groupInfo.value?.name
|
||
return remark && name && remark !== name ? name : ''
|
||
})
|
||
|
||
const BOTTOM_THRESHOLD = 80 // "是否停留在底部"的阈值:距离底部 < 80px 视为底部
|
||
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
|
||
groupRemark?: 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,
|
||
groupRemark: group?.groupRemark,
|
||
ownerId: group?.ownerUserId,
|
||
memberCount: group?.memberCount,
|
||
joinApproval: group?.joinApproval
|
||
}
|
||
})
|
||
|
||
// 群成员列表:直接取 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,
|
||
role: member.role
|
||
}
|
||
})
|
||
})
|
||
|
||
/** 切换到群会话时同步群信息 + 成员;各自 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)
|
||
}
|
||
|
||
/** 历史消息抽屉 ref:「聊天历史」icon / 抽屉「查找聊天内容」入口都调 open() 触发 */
|
||
const historyDialogRef = ref<InstanceType<typeof MessageHistory>>()
|
||
const sideVisible = ref(false) // 信息抽屉开关:群聊 / 私聊共用一个 ref
|
||
const muteMemberDialogRef = ref<InstanceType<typeof GroupMuteMemberDialog>>()
|
||
const callMemberPickerRef = ref<InstanceType<typeof RtcCallMemberPickerDialog>>()
|
||
/** 群通话发起:成员选择弹窗打开期间临时持有的 mediaType */
|
||
const pendingMediaType = ref<number | null>(null)
|
||
|
||
/** 消息右键菜单「禁言」→ 打开时长选择弹窗 */
|
||
function handleMuteMember(groupId: number, userId: number, displayName: string) {
|
||
muteMemberDialogRef.value?.open(groupId, userId, displayName)
|
||
}
|
||
|
||
/** 信息抽屉的 toggle:跟 header 上 3 点图标按钮共用 */
|
||
function toggleSide() {
|
||
sideVisible.value = !sideVisible.value
|
||
}
|
||
|
||
/** 私聊通话入口:popover 触发;点 语音 / 视频 直接发起 */
|
||
const callPopoverVisible = ref(false)
|
||
async function startPrivateCall(mediaType: number) {
|
||
callPopoverVisible.value = false
|
||
const conversation = conversationStore.activeConversation
|
||
if (!conversation) {
|
||
return
|
||
}
|
||
await doInvite({
|
||
conversationType: ImConversationType.PRIVATE,
|
||
mediaType,
|
||
inviteeIds: [conversation.targetId]
|
||
})
|
||
}
|
||
|
||
/** 群通话入口:默认语音直接弹选人;与微信群通话一致,进通话后用户按需开摄像头 */
|
||
function handleGroupCall() {
|
||
const conversation = conversationStore.activeConversation
|
||
if (!conversation) {
|
||
return
|
||
}
|
||
pendingMediaType.value = ImRtcCallMediaType.VOICE
|
||
callMemberPickerRef.value?.open({ groupId: conversation.targetId, mode: 'invite' })
|
||
}
|
||
|
||
/** 选人弹窗确认;带选中 ID 发起群通话 */
|
||
async function onCallMemberPicked(selectedIds: number[]) {
|
||
const conversation = conversationStore.activeConversation
|
||
const mediaType = pendingMediaType.value
|
||
pendingMediaType.value = null
|
||
if (!conversation || mediaType == null || selectedIds.length === 0) {
|
||
return
|
||
}
|
||
await doInvite({
|
||
conversationType: ImConversationType.GROUP,
|
||
mediaType,
|
||
groupId: conversation.targetId,
|
||
inviteeIds: selectedIds
|
||
})
|
||
}
|
||
|
||
/** 实际调 create 接口;统一处理成功 / ENDED(如忙线立即结束)/ 异常三种返回 */
|
||
async function doInvite(reqVO: {
|
||
conversationType: number
|
||
mediaType: number
|
||
groupId?: number
|
||
inviteeIds: number[]
|
||
}) {
|
||
try {
|
||
const data = await createCall(reqVO)
|
||
// 后端已 INSERT + 立即 end(如忙线):toast 提示,不进 INVITING 阶段;chat tip 由 RTC_CALL_END 推送写入消息流
|
||
if (data.status === ImRtcCallStatus.ENDED) {
|
||
message.warning(resolveCallEndReasonText(data.endReason))
|
||
return
|
||
}
|
||
// 正常进入 INVITING 阶段:走 store 逻辑发起通话,后续状态更新 / 消息流更新由 RTC 模块监听推送处理
|
||
rtcStore.startInviting(data)
|
||
} catch (e: any) {
|
||
message.error(e?.msg || '发起通话失败')
|
||
}
|
||
}
|
||
|
||
/** 当前私聊对应的好友(抽屉头部展示用) */
|
||
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
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 滚到底部:切会话 / 收到新消息(且当前在底部)/ 用户主动点「回到底部」 都走这里
|
||
*
|
||
* smooth=true 走平滑动画,适合用户主动点击;初始 / 自动滚动用 auto,避免用户感知到动画拖拽
|
||
*/
|
||
async function scrollToBottom(smooth = false) {
|
||
// 1. 滚到当前 scrollHeight 的底部(图片 / 视频还在加载时只是大致到底)
|
||
// 1.1 等 v-for 把新消息真正渲染进 DOM 后再算 scrollHeight,否则差最后一条的位置
|
||
await nextTick()
|
||
if (!listRef.value) {
|
||
return
|
||
}
|
||
// 1.2 触发滚动;smooth 仅 user 主动点「回到底部」用,初始 / 自动滚走 auto 避免动画拖拽感
|
||
listRef.value.scrollTo({
|
||
top: listRef.value.scrollHeight,
|
||
behavior: smooth ? 'smooth' : 'auto'
|
||
})
|
||
newMessageCount.value = 0
|
||
showJumpToBottom.value = false
|
||
// 1.3 记下「期望停下的 scrollTop」;图片 / 视频加载完底部会上移,scrollTop 没动就说明用户没手动滚走
|
||
// 不能用 distanceFromBottom 判断:底部上移会让 distance 变大,被误判为「用户滚走了」直接放弃补滚
|
||
const expectedScrollTop = listRef.value.scrollHeight - listRef.value.clientHeight
|
||
|
||
// 2. 等媒体加载完后补滚到真实底部
|
||
// 2.1 等容器内未加载完的图片 / 视频元数据;加载完后 scrollHeight 会增长到真实底部
|
||
await waitMediaSettled()
|
||
if (!listRef.value) {
|
||
return
|
||
}
|
||
// 2.2 仅在用户没手动滚走时(scrollTop 仍贴近 expectedScrollTop)才补滚,避免等待期间用户上翻被打断
|
||
if (Math.abs(listRef.value.scrollTop - expectedScrollTop) > BOTTOM_THRESHOLD) {
|
||
return
|
||
}
|
||
// 2.3 补滚到新底部
|
||
listRef.value.scrollTo({ top: listRef.value.scrollHeight, behavior: 'auto' })
|
||
}
|
||
|
||
/**
|
||
* 等待容器内未加载完的图片 / 视频;最多等 2s 防止超大资源把整个滚动跟进卡住
|
||
*
|
||
* 仅关心元数据(loadedmetadata / img.complete),不等真正解码,因为尺寸够算 scrollHeight 就行
|
||
*/
|
||
function waitMediaSettled(): Promise<void> {
|
||
// 1. 收集容器内未加载完的图片 / 视频
|
||
if (!listRef.value) {
|
||
return Promise.resolve()
|
||
}
|
||
// 1.1 一次扫 img + video,按 element 类型分别看「complete / readyState」过滤 pending
|
||
const pendingMedia = Array.from(
|
||
listRef.value.querySelectorAll<HTMLImageElement | HTMLVideoElement>('img, video')
|
||
).filter((el) => (el instanceof HTMLImageElement ? !el.complete : el.readyState < 1))
|
||
// 1.2 没有 pending 直接返回,省掉 Promise.race / setTimeout 闭包构造
|
||
if (pendingMedia.length === 0) {
|
||
return Promise.resolve()
|
||
}
|
||
|
||
// 2. 等所有 pending 资源 load / error,最长 2s 兜底
|
||
// 2.1 每个 element 都监听对应 loadedEvent + error,任一触发即 resolve(不让单条失败卡 race)
|
||
const loadAll = Promise.all(
|
||
pendingMedia.map(
|
||
(el) =>
|
||
new Promise<void>((resolve) => {
|
||
const loadedEvent = el instanceof HTMLImageElement ? 'load' : 'loadedmetadata'
|
||
el.addEventListener(loadedEvent, () => resolve(), { once: true })
|
||
el.addEventListener('error', () => resolve(), { once: true })
|
||
})
|
||
)
|
||
).then(() => undefined)
|
||
// 2.2 2s 超时兜底,防止超大资源 / 网络挂起把整个滚动跟进永久卡住
|
||
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000))
|
||
return Promise.race([loadAll, timeout])
|
||
}
|
||
|
||
/**
|
||
* 定位到聊天位置:MessageHistory 行上"定位"按钮 / 气泡内引用块点击触发
|
||
*
|
||
* 1. 先关掉历史弹窗(避免 scroll 时遮挡 + dialog 关闭后让聊天面板拿回焦点)
|
||
* 2. nextTick 等弹窗 leave 动画 / 列表渲染稳定后再查 DOM
|
||
* 3. 按 data-message-id 找 wrapper,scrollIntoView({ block: center }) 让消息落到视口中部
|
||
* 4. 加 --highlight class 短暂高亮,提示用户"就是这条"
|
||
* 5. 找不到 wrapper(原消息已分页出去)时弹 warning 提示,与微信"消息已不在窗口"观感一致
|
||
*/
|
||
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) {
|
||
message.warning('原消息不在视野')
|
||
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_THRESHOLD(80px)做容差:用户稍微往上翻几行就视作"不在底部",
|
||
// 否则一直 auto-scroll 会把人正在读的内容顶走,体验糟糕
|
||
const dist = distanceFromBottom()
|
||
if (dist <= BOTTOM_THRESHOLD) {
|
||
scrollToBottom()
|
||
} else {
|
||
newMessageCount.value += delta
|
||
showJumpToBottom.value = true
|
||
}
|
||
}
|
||
)
|
||
|
||
/**
|
||
* 切换会话:清空"在不在底部"相关状态、强制滚到底部、群会话预拉资料
|
||
*
|
||
* type+targetId 一起监听:私聊与群聊 id 同号时切换也能触发;immediate:true 让首次进入页面就能初始化
|
||
*/
|
||
watch(
|
||
() => [
|
||
conversationStore.activeConversation?.type,
|
||
conversationStore.activeConversation?.targetId
|
||
],
|
||
([type, targetId]) => {
|
||
// 切会话时上一会话的「未读累计 + 浮窗显示」必须清掉,否则会带到新会话里看起来很突兀
|
||
newMessageCount.value = 0
|
||
showJumpToBottom.value = false
|
||
// 抽屉里展示的群信息 / 好友信息属于上一会话,切会话时统一关掉
|
||
sideVisible.value = false
|
||
scrollToBottom()
|
||
// 仅群聊预拉详情 / 成员(私聊对端在首屏 fetchFriends 时就拉了)
|
||
if (targetId && type === ImConversationType.GROUP) {
|
||
ensureGroupData(targetId)
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 顶部分隔线:UnoCSS 不带 border-style preflight,class 写法只设色 / 宽不出线,走 scoped 显式 shorthand */
|
||
.message-panel__header {
|
||
flex-shrink: 0;
|
||
border-bottom: 1px solid var(--el-border-color-light);
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
/* 「对方还不是你的朋友」胶囊:嵌在 header 内(跟群置顶同级),不占整行;padding 跟群置顶 padding 对齐 */
|
||
.message-panel__not-friend-container {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
padding: 0 16px 8px;
|
||
background-color: var(--el-fill-color-light);
|
||
}
|
||
.message-panel__not-friend {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 4px 10px;
|
||
border-radius: 999px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
color: var(--el-text-color-primary);
|
||
background-color: var(--el-color-warning-light-9);
|
||
transition: background-color 0.15s;
|
||
}
|
||
.message-panel__not-friend:hover {
|
||
background-color: var(--el-color-warning-light-8);
|
||
}
|
||
/* 圆形小图标,深黄底色配白色 icon —— 跟微信一致的视觉锤 */
|
||
.message-panel__not-friend-icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 50%;
|
||
color: #fff;
|
||
background-color: var(--el-color-warning);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 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);
|
||
}
|
||
|
||
.message-panel__call-menu {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.message-panel__call-menu-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 12px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
color: var(--el-text-color-primary);
|
||
}
|
||
|
||
.message-panel__call-menu-item:hover {
|
||
background-color: var(--el-fill-color-light);
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
/* el-popover 全局样式:紧贴菜单的小 padding;非 scoped 才能命中 popper-class */
|
||
.message-panel__call-popover.el-popover.el-popper {
|
||
min-width: auto;
|
||
padding: 6px;
|
||
}
|
||
</style>
|