✨ feat(im): 优化 rtc 整体弹窗界面
parent
a170ae37ab
commit
e579a4de13
|
|
@ -22,6 +22,9 @@
|
||||||
<UserInfoCard />
|
<UserInfoCard />
|
||||||
<GroupInfoCard />
|
<GroupInfoCard />
|
||||||
<ContextMenu />
|
<ContextMenu />
|
||||||
|
|
||||||
|
<!-- 实时通话浮层;监听 rtcStore 全局状态,可在任意 IM 子页弹出 -->
|
||||||
|
<CallContainer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -47,6 +50,7 @@ import ToolBar from './components/ToolBar.vue'
|
||||||
import UserInfoCard from './components/user/UserInfoCard.vue'
|
import UserInfoCard from './components/user/UserInfoCard.vue'
|
||||||
import GroupInfoCard from './components/group/GroupInfoCard.vue'
|
import GroupInfoCard from './components/group/GroupInfoCard.vue'
|
||||||
import ContextMenu from './components/ContextMenu.vue'
|
import ContextMenu from './components/ContextMenu.vue'
|
||||||
|
import CallContainer from './components/rtc/CallContainer.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'ImIndex' })
|
defineOptions({ name: 'ImIndex' })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,13 @@
|
||||||
:size="36"
|
:size="36"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="flex gap-2 items-center px-3.5 py-2 text-sm rounded-lg"
|
class="flex gap-2 items-center px-3.5 py-2 text-sm rounded-lg cursor-pointer"
|
||||||
:class="
|
:class="
|
||||||
message.selfSend
|
message.selfSend
|
||||||
? 'text-black bg-[#95ec69]'
|
? 'text-black bg-[#95ec69]'
|
||||||
: 'text-[var(--el-text-color-primary)] bg-[var(--el-fill-color-light)]'
|
: 'text-[var(--el-text-color-primary)] bg-[var(--el-fill-color-light)]'
|
||||||
"
|
"
|
||||||
|
@click="handleRtcCallBubbleClick"
|
||||||
>
|
>
|
||||||
<Icon icon="ant-design:phone-outlined" :size="16" class="rotate-[135deg] flex-shrink-0" />
|
<Icon icon="ant-design:phone-outlined" :size="16" class="rotate-[135deg] flex-shrink-0" />
|
||||||
<span class="whitespace-nowrap">{{ rtcCallPrivateBubbleText }}</span>
|
<span class="whitespace-nowrap">{{ rtcCallPrivateBubbleText }}</span>
|
||||||
|
|
@ -251,7 +252,7 @@ import ReplyPreview from './ReplyPreview.vue'
|
||||||
import TipSegments from './TipSegments.vue'
|
import TipSegments from './TipSegments.vue'
|
||||||
import UserAvatar from '../../../../components/user/UserAvatar.vue'
|
import UserAvatar from '../../../../components/user/UserAvatar.vue'
|
||||||
import MessageBubble from './MessageBubble.vue'
|
import MessageBubble from './MessageBubble.vue'
|
||||||
import { IM_FORWARD_DIALOG_KEY, IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys'
|
import { IM_FORWARD_DIALOG_KEY, IM_MERGE_DETAIL_DIALOG_KEY, IM_RTC_REDIAL_KEY } from './forward/keys'
|
||||||
import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect'
|
import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect'
|
||||||
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
||||||
|
|
||||||
|
|
@ -376,6 +377,16 @@ const quote = computed(() => getQuoteFromMessage(props.message.content))
|
||||||
/** MessagePanel 注入的弹窗触发函数 */
|
/** MessagePanel 注入的弹窗触发函数 */
|
||||||
const openForwardDialog = inject(IM_FORWARD_DIALOG_KEY)
|
const openForwardDialog = inject(IM_FORWARD_DIALOG_KEY)
|
||||||
const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY)
|
const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY)
|
||||||
|
const redialRtcCall = inject(IM_RTC_REDIAL_KEY)
|
||||||
|
|
||||||
|
/** 私聊 RTC_CALL_END 气泡点击:用同款 mediaType 重拨 */
|
||||||
|
function handleRtcCallBubbleClick() {
|
||||||
|
const mediaType = rtcCallEndPrivatePayload.value?.mediaType
|
||||||
|
if (mediaType == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redialRtcCall?.(mediaType)
|
||||||
|
}
|
||||||
|
|
||||||
/** 多选模式:模块级单例 composable */
|
/** 多选模式:模块级单例 composable */
|
||||||
const multiSelect = useMessageMultiSelect()
|
const multiSelect = useMessageMultiSelect()
|
||||||
|
|
|
||||||
|
|
@ -36,13 +36,45 @@
|
||||||
@click="historyDialogRef?.open()"
|
@click="historyDialogRef?.open()"
|
||||||
/>
|
/>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<!-- 通话入口:暂未开放,先放占位图标对齐微信 PC -->
|
<!-- 通话入口:私聊弹「语音 / 视频」popover;群聊直接进选人弹窗 -->
|
||||||
<el-tooltip content="通话" placement="bottom">
|
<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
|
||||||
icon="ant-design:phone-outlined"
|
icon="ant-design:phone-outlined"
|
||||||
:size="20"
|
:size="20"
|
||||||
class="message-panel__header-icon cursor-pointer"
|
class="message-panel__header-icon cursor-pointer"
|
||||||
@click="handleCall"
|
@click="handleGroupCall"
|
||||||
/>
|
/>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<!-- 信息抽屉入口 -->
|
<!-- 信息抽屉入口 -->
|
||||||
|
|
@ -57,6 +89,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 群通话胶囊条:仅群聊 + 该群有活跃通话时显示;点击展开看成员 + 加入按钮 -->
|
||||||
|
<GroupCallBanner
|
||||||
|
v-if="isGroup && conversationStore.activeConversation"
|
||||||
|
:group-id="conversationStore.activeConversation.targetId"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 群置顶消息:第二行嵌入 header;仅群聊 + 有置顶时显示 -->
|
<!-- 群置顶消息:第二行嵌入 header;仅群聊 + 有置顶时显示 -->
|
||||||
<GroupPinnedMessage
|
<GroupPinnedMessage
|
||||||
v-if="isGroup && conversationStore.activeConversation"
|
v-if="isGroup && conversationStore.activeConversation"
|
||||||
|
|
@ -75,11 +113,7 @@
|
||||||
<Icon icon="ant-design:user-outlined" :size="11" />
|
<Icon icon="ant-design:user-outlined" :size="11" />
|
||||||
</span>
|
</span>
|
||||||
<span>对方还不是你的朋友</span>
|
<span>对方还不是你的朋友</span>
|
||||||
<Icon
|
<Icon icon="ep:arrow-right" :size="12" class="text-[var(--el-text-color-secondary)]" />
|
||||||
icon="ep:arrow-right"
|
|
||||||
:size="12"
|
|
||||||
class="text-[var(--el-text-color-secondary)]"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -132,10 +166,7 @@
|
||||||
<!-- 底部:输入框常驻;多选模式底栏作为浮层盖在上面,保持下方输入框尺寸不变 -->
|
<!-- 底部:输入框常驻;多选模式底栏作为浮层盖在上面,保持下方输入框尺寸不变 -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<MessageInput />
|
<MessageInput />
|
||||||
<MessageMultiSelectBar
|
<MessageMultiSelectBar v-if="multiSelect.state.active" class="absolute inset-0 z-10" />
|
||||||
v-if="multiSelect.state.active"
|
|
||||||
class="absolute inset-0 z-10"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧信息抽屉:群聊 / 私聊各自一份 -->
|
<!-- 右侧信息抽屉:群聊 / 私聊各自一份 -->
|
||||||
|
|
@ -165,6 +196,9 @@
|
||||||
|
|
||||||
<!-- 禁言时长选择弹窗 -->
|
<!-- 禁言时长选择弹窗 -->
|
||||||
<GroupMuteMemberDialog ref="muteMemberDialogRef" @success="reloadGroupData" />
|
<GroupMuteMemberDialog ref="muteMemberDialogRef" @success="reloadGroupData" />
|
||||||
|
|
||||||
|
<!-- 群通话成员选择弹窗 -->
|
||||||
|
<CallMemberPickerDialog ref="callMemberPickerRef" @success="onCallMemberPicked" />
|
||||||
</template>
|
</template>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
|
|
@ -185,13 +219,16 @@ import { useFriendStore } from '../../../../store/friendStore'
|
||||||
import { useImUiStore } from '../../../../store/uiStore'
|
import { useImUiStore } from '../../../../store/uiStore'
|
||||||
import { getMemberDisplayName } from '@/views/im/utils/user'
|
import { getMemberDisplayName } from '@/views/im/utils/user'
|
||||||
import { useGroupStore } from '../../../../store/groupStore'
|
import { useGroupStore } from '../../../../store/groupStore'
|
||||||
import { ImConversationType } from '@/views/im/utils/constants'
|
|
||||||
import MessageItem from './MessageItem.vue'
|
import MessageItem from './MessageItem.vue'
|
||||||
import MessageInput from '../input/MessageInput.vue'
|
import MessageInput from '../input/MessageInput.vue'
|
||||||
import MessageMultiSelectBar from '../input/MessageMultiSelectBar.vue'
|
import MessageMultiSelectBar from '../input/MessageMultiSelectBar.vue'
|
||||||
import MessageForwardDialog from './forward/MessageForwardDialog.vue'
|
import MessageForwardDialog from './forward/MessageForwardDialog.vue'
|
||||||
import MessageMergeDetailDialog from './forward/MessageMergeDetailDialog.vue'
|
import MessageMergeDetailDialog from './forward/MessageMergeDetailDialog.vue'
|
||||||
import { IM_FORWARD_DIALOG_KEY, IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys'
|
import {
|
||||||
|
IM_FORWARD_DIALOG_KEY,
|
||||||
|
IM_MERGE_DETAIL_DIALOG_KEY,
|
||||||
|
IM_RTC_REDIAL_KEY
|
||||||
|
} from './forward/keys'
|
||||||
import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect'
|
import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect'
|
||||||
import { useVoicePlayer } from '../../../../composables/useVoicePlayer'
|
import { useVoicePlayer } from '../../../../composables/useVoicePlayer'
|
||||||
import MessageHistory from './MessageHistory.vue'
|
import MessageHistory from './MessageHistory.vue'
|
||||||
|
|
@ -202,6 +239,12 @@ import ConversationPrivateSide from '../conversation/ConversationPrivateSide.vue
|
||||||
import type { GroupLite } from '../../../../types'
|
import type { GroupLite } from '../../../../types'
|
||||||
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
||||||
import GroupMuteMemberDialog from '../../../../components/group/GroupMuteMemberDialog.vue'
|
import GroupMuteMemberDialog from '../../../../components/group/GroupMuteMemberDialog.vue'
|
||||||
|
import CallMemberPickerDialog from '../../../../components/rtc/CallMemberPickerDialog.vue'
|
||||||
|
import GroupCallBanner from '../../../../components/rtc/GroupCallBanner.vue'
|
||||||
|
import { createCall } from '@/api/im/home/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' })
|
defineOptions({ name: 'ImMessagePanel' })
|
||||||
|
|
||||||
|
|
@ -210,6 +253,7 @@ const friendStore = useFriendStore()
|
||||||
const uiStore = useImUiStore()
|
const uiStore = useImUiStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const rtcStore = useRtcStore()
|
||||||
const listRef = ref<HTMLElement>()
|
const listRef = ref<HTMLElement>()
|
||||||
|
|
||||||
// ==================== 转发 / 合并消息详情:本地 dialog 浮层 ====================
|
// ==================== 转发 / 合并消息详情:本地 dialog 浮层 ====================
|
||||||
|
|
@ -220,6 +264,11 @@ const mergeDetailDialogRef = ref<InstanceType<typeof MessageMergeDetailDialog>>(
|
||||||
|
|
||||||
provide(IM_FORWARD_DIALOG_KEY, (opts) => forwardDialogRef.value?.open(opts))
|
provide(IM_FORWARD_DIALOG_KEY, (opts) => forwardDialogRef.value?.open(opts))
|
||||||
provide(IM_MERGE_DETAIL_DIALOG_KEY, (content) => mergeDetailDialogRef.value?.open(content))
|
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 显隐判定
|
// 模块级单例 state(composable);本组件仅做切会话退出 + template 显隐判定
|
||||||
|
|
@ -243,6 +292,9 @@ const messages = computed(() => conversationStore.getActiveMessages)
|
||||||
const isGroup = computed(
|
const isGroup = computed(
|
||||||
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
|
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
|
||||||
)
|
)
|
||||||
|
const isPrivate = computed(
|
||||||
|
() => conversationStore.activeConversation?.type === ImConversationType.PRIVATE
|
||||||
|
)
|
||||||
|
|
||||||
/** 私聊会话且对端不是有效好友(本端 friend 记录缺失或 DISABLE);单边删除语义下「被对方删除」不触发本端横幅 */
|
/** 私聊会话且对端不是有效好友(本端 friend 记录缺失或 DISABLE);单边删除语义下「被对方删除」不触发本端横幅 */
|
||||||
const showNotFriendBanner = computed(() => {
|
const showNotFriendBanner = computed(() => {
|
||||||
|
|
@ -383,6 +435,9 @@ function reloadGroupData() {
|
||||||
const historyDialogRef = ref<InstanceType<typeof MessageHistory>>()
|
const historyDialogRef = ref<InstanceType<typeof MessageHistory>>()
|
||||||
const sideVisible = ref(false) // 信息抽屉开关:群聊 / 私聊共用一个 ref
|
const sideVisible = ref(false) // 信息抽屉开关:群聊 / 私聊共用一个 ref
|
||||||
const muteMemberDialogRef = ref<InstanceType<typeof GroupMuteMemberDialog>>()
|
const muteMemberDialogRef = ref<InstanceType<typeof GroupMuteMemberDialog>>()
|
||||||
|
const callMemberPickerRef = ref<InstanceType<typeof CallMemberPickerDialog>>()
|
||||||
|
/** 群通话发起:成员选择弹窗打开期间临时持有的 mediaType */
|
||||||
|
const pendingMediaType = ref<number | null>(null)
|
||||||
|
|
||||||
/** 消息右键菜单「禁言」→ 打开时长选择弹窗 */
|
/** 消息右键菜单「禁言」→ 打开时长选择弹窗 */
|
||||||
function handleMuteMember(groupId: number, userId: number, displayName: string) {
|
function handleMuteMember(groupId: number, userId: number, displayName: string) {
|
||||||
|
|
@ -394,9 +449,75 @@ function toggleSide() {
|
||||||
sideVisible.value = !sideVisible.value
|
sideVisible.value = !sideVisible.value
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 通话入口:功能未开放,先弹提示占位 */
|
/** 私聊通话入口:popover 触发;点 语音 / 视频 直接发起 */
|
||||||
function handleCall() {
|
const callPopoverVisible = ref(false)
|
||||||
message.warning('通话功能暂未开放')
|
async function startPrivateCall(mediaType: number) {
|
||||||
|
callPopoverVisible.value = false
|
||||||
|
const conversation = conversationStore.activeConversation
|
||||||
|
if (!conversation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await doInvite(
|
||||||
|
{
|
||||||
|
conversationType: ImConversationType.PRIVATE,
|
||||||
|
mediaType,
|
||||||
|
inviteeIds: [conversation.targetId]
|
||||||
|
},
|
||||||
|
{ nickname: conversation.name, avatar: conversation.avatar }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群通话入口:默认语音直接弹选人;与微信群通话一致,进通话后用户按需开摄像头 */
|
||||||
|
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
|
||||||
|
},
|
||||||
|
{ nickname: conversation.name, avatar: conversation.avatar }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实际调 create 接口;统一处理成功 / ENDED(如忙线立即结束)/ 异常三种返回 */
|
||||||
|
async function doInvite(
|
||||||
|
reqVO: {
|
||||||
|
conversationType: number
|
||||||
|
mediaType: number
|
||||||
|
groupId?: number
|
||||||
|
inviteeIds: number[]
|
||||||
|
},
|
||||||
|
peer: { nickname?: string; avatar?: string }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const resp = await createCall(reqVO)
|
||||||
|
// 后端已 INSERT + 立即 end(如忙线):toast 提示,不进 INVITING 阶段;chat tip 由 RTC_CALL_END 推送写入消息流
|
||||||
|
if (resp.status === ImRtcCallStatus.ENDED) {
|
||||||
|
message.warning(resolveCallEndReasonText(resp.endReason))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 正常进入 INVITING 阶段:走 store 逻辑发起通话,后续状态更新 / 消息流更新由 RTC 模块监听推送处理
|
||||||
|
rtcStore.startInviting(resp, peer)
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.msg || '发起通话失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前私聊对应的好友(抽屉头部展示用) */
|
/** 当前私聊对应的好友(抽屉头部展示用) */
|
||||||
|
|
@ -670,4 +791,33 @@ watch(
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(-50%, 20px);
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,12 @@ export type OpenForwardDialog = (opts: {
|
||||||
/** 打开合并消息详情弹窗 */
|
/** 打开合并消息详情弹窗 */
|
||||||
export type OpenMergeDetailDialog = (content: string) => void
|
export type OpenMergeDetailDialog = (content: string) => void
|
||||||
|
|
||||||
|
/** 重拨 RTC 通话;点私聊 RTC_CALL_END 气泡触发 */
|
||||||
|
export type RtcRedial = (mediaType: number) => void
|
||||||
|
|
||||||
/** MessagePanel 通过 provide 暴露给子树 */
|
/** MessagePanel 通过 provide 暴露给子树 */
|
||||||
export const IM_FORWARD_DIALOG_KEY: InjectionKey<OpenForwardDialog> = Symbol('IM_FORWARD_DIALOG')
|
export const IM_FORWARD_DIALOG_KEY: InjectionKey<OpenForwardDialog> = Symbol('IM_FORWARD_DIALOG')
|
||||||
export const IM_MERGE_DETAIL_DIALOG_KEY: InjectionKey<OpenMergeDetailDialog> = Symbol(
|
export const IM_MERGE_DETAIL_DIALOG_KEY: InjectionKey<OpenMergeDetailDialog> = Symbol(
|
||||||
'IM_MERGE_DETAIL_DIALOG'
|
'IM_MERGE_DETAIL_DIALOG'
|
||||||
)
|
)
|
||||||
|
export const IM_RTC_REDIAL_KEY: InjectionKey<RtcRedial> = Symbol('IM_RTC_REDIAL')
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,26 @@ import {
|
||||||
ImWebSocketMessageType,
|
ImWebSocketMessageType,
|
||||||
ImMessageType,
|
ImMessageType,
|
||||||
ImConversationType,
|
ImConversationType,
|
||||||
|
ImRtcParticipantStatus,
|
||||||
isFriendChatTip,
|
isFriendChatTip,
|
||||||
isFriendNotification,
|
isFriendNotification,
|
||||||
isGroupRequestNotification,
|
isGroupRequestNotification,
|
||||||
isNormalMessage
|
isNormalMessage
|
||||||
} from '../../utils/constants'
|
} from '../../utils/constants'
|
||||||
import { playAudioTip } from '../../utils/message'
|
import { playAudioTip, resolveCallEndReasonText } from '../../utils/message'
|
||||||
import { MESSAGE_PRIVATE_READ_ENABLED, MESSAGE_GROUP_READ_ENABLED } from '../../utils/config'
|
import { MESSAGE_PRIVATE_READ_ENABLED, MESSAGE_GROUP_READ_ENABLED } from '../../utils/config'
|
||||||
import { useConversationStore } from './conversationStore'
|
import { useConversationStore } from './conversationStore'
|
||||||
import { useFriendStore, type FriendNotificationPayload } from './friendStore'
|
import { useFriendStore, type FriendNotificationPayload } from './friendStore'
|
||||||
import { getFriendDisplayName } from '../../utils/user'
|
import { getFriendDisplayName } from '../../utils/user'
|
||||||
import { useGroupStore } from './groupStore'
|
import { useGroupStore } from './groupStore'
|
||||||
import { useGroupRequestStore } from './groupRequestStore'
|
import { useGroupRequestStore } from './groupRequestStore'
|
||||||
|
import {
|
||||||
|
useRtcStore,
|
||||||
|
type ImRtcCallNotification,
|
||||||
|
type ImRtcParticipantConnectedNotification,
|
||||||
|
type ImRtcParticipantDisconnectedNotification,
|
||||||
|
type ImRtcCallEndNotification
|
||||||
|
} from './rtcStore'
|
||||||
import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/home/message/private'
|
import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/home/message/private'
|
||||||
import { readGroupMessages as apiReadGroupMessages } from '@/api/im/home/message/group'
|
import { readGroupMessages as apiReadGroupMessages } from '@/api/im/home/message/group'
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -238,6 +246,16 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
case ImMessageType.RECEIPT:
|
case ImMessageType.RECEIPT:
|
||||||
this.handlePrivateReceipt(websocketMessage)
|
this.handlePrivateReceipt(websocketMessage)
|
||||||
break
|
break
|
||||||
|
case ImMessageType.RTC_CALL:
|
||||||
|
case ImMessageType.RTC_PARTICIPANT_CONNECTED:
|
||||||
|
case ImMessageType.RTC_PARTICIPANT_DISCONNECTED:
|
||||||
|
this.handleRtcSignaling(websocketMessage)
|
||||||
|
break
|
||||||
|
case ImMessageType.RTC_CALL_END:
|
||||||
|
// 入库 + 关闭通话窗 + 渲染聊天 tip(私聊场景)
|
||||||
|
this.handleRtcCallEnd(websocketMessage)
|
||||||
|
this.handlePrivateMessage(websocketMessage)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
if (isFriendNotification(websocketMessage.type)) {
|
if (isFriendNotification(websocketMessage.type)) {
|
||||||
this.handleFriendNotification(websocketMessage)
|
this.handleFriendNotification(websocketMessage)
|
||||||
|
|
@ -282,6 +300,15 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
case ImMessageType.GROUP_MEMBER_SETTING_UPDATE:
|
case ImMessageType.GROUP_MEMBER_SETTING_UPDATE:
|
||||||
this.handleGroupMemberSettingUpdate(websocketMessage)
|
this.handleGroupMemberSettingUpdate(websocketMessage)
|
||||||
break
|
break
|
||||||
|
case ImMessageType.RTC_CALL_START:
|
||||||
|
// 入库 + 渲染聊天 tip;胶囊条状态走 1602/1603,本帧不动 rtcStore,避免与首次填充竞争
|
||||||
|
this.handleGroupMessage(websocketMessage)
|
||||||
|
break
|
||||||
|
case ImMessageType.RTC_CALL_END:
|
||||||
|
// 入库 + 移除胶囊条 + 关闭通话窗(如果当前在该群通话内)
|
||||||
|
this.handleRtcCallEnd(websocketMessage)
|
||||||
|
this.handleGroupMessage(websocketMessage)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
// TEXT / IMAGE / FILE / VOICE / VIDEO + GROUP_* 群广播事件
|
// TEXT / IMAGE / FILE / VOICE / VIDEO + GROUP_* 群广播事件
|
||||||
this.handleGroupMessage(websocketMessage)
|
this.handleGroupMessage(websocketMessage)
|
||||||
|
|
@ -689,6 +716,93 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
||||||
clearInterval(this.heartbeatTimer)
|
clearInterval(this.heartbeatTimer)
|
||||||
this.heartbeatTimer = null
|
this.heartbeatTimer = null
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 实时通话信令分发 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通话信令分发:1601 RTC_CALL(按 status 区分 INVITING / JOINED / REJECTED / NO_ANSWER / LEFT)+ 1602 / 1603 参与者加入 / 离开
|
||||||
|
* <p>
|
||||||
|
* 单一 dispatcher,单次 JSON 解析,mirror handleFriendNotification 的结构
|
||||||
|
*/
|
||||||
|
handleRtcSignaling(websocketMessage: ImPrivateMessageDTO) {
|
||||||
|
const rtcStore = useRtcStore()
|
||||||
|
switch (websocketMessage.type) {
|
||||||
|
case ImMessageType.RTC_CALL: {
|
||||||
|
const payload = this.safeParse(websocketMessage.content) as ImRtcCallNotification | null
|
||||||
|
if (!payload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch (payload.status) {
|
||||||
|
case ImRtcParticipantStatus.INVITING:
|
||||||
|
// 当前已在通话中:忽略新来电;后端层面也会拒绝,这里是兜底
|
||||||
|
if (!rtcStore.isActive) {
|
||||||
|
rtcStore.showIncoming(payload)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case ImRtcParticipantStatus.REJECTED:
|
||||||
|
// 群通话单人拒绝;把拒绝者从 pending 占位移除(私聊拒绝走 RTC_CALL_END 入消息流,不走本通道)
|
||||||
|
if (payload.operatorUserId) {
|
||||||
|
rtcStore.markUserLeft(payload.operatorUserId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case ImRtcParticipantStatus.JOINED:
|
||||||
|
case ImRtcParticipantStatus.NO_ANSWER:
|
||||||
|
case ImRtcParticipantStatus.LEFT:
|
||||||
|
// ACCEPT / CANCEL / HUNGUP 暂不需要本端额外响应;rtcStore 状态由 1602/1603 + END 维护
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.warn('[IM WS] 未识别的 RTC_CALL status', payload)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case ImMessageType.RTC_PARTICIPANT_CONNECTED: {
|
||||||
|
const payload = this.safeParse(
|
||||||
|
websocketMessage.content
|
||||||
|
) as ImRtcParticipantConnectedNotification | null
|
||||||
|
if (payload?.room && payload.userId) {
|
||||||
|
rtcStore.applyParticipantConnected(payload)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case ImMessageType.RTC_PARTICIPANT_DISCONNECTED: {
|
||||||
|
const payload = this.safeParse(
|
||||||
|
websocketMessage.content
|
||||||
|
) as ImRtcParticipantDisconnectedNotification | null
|
||||||
|
if (payload?.room && payload.userId) {
|
||||||
|
rtcStore.applyParticipantDisconnected(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RTC_CALL_END 通话结束;私聊 + 群聊都走这一条;payload 携带 conversationType 区分
|
||||||
|
* <p>
|
||||||
|
* 私聊:关闭当前通话窗
|
||||||
|
* 群聊:移除胶囊条;如本端在该群通话内则关闭通话窗
|
||||||
|
*/
|
||||||
|
handleRtcCallEnd(websocketMessage: ImPrivateMessageDTO | ImGroupMessageDTO) {
|
||||||
|
const payload = this.safeParse(websocketMessage.content) as ImRtcCallEndNotification | null
|
||||||
|
if (!payload?.room) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rtcStore = useRtcStore()
|
||||||
|
const isGroup = payload.conversationType === ImConversationType.GROUP
|
||||||
|
// 群通话:移除胶囊条(按外层 groupId 取,不依赖 payload)
|
||||||
|
const groupId = (websocketMessage as ImGroupMessageDTO).groupId
|
||||||
|
if (isGroup && groupId) {
|
||||||
|
rtcStore.removeGroupCall(groupId)
|
||||||
|
}
|
||||||
|
// 通话窗 / 来电窗指向同一 room 时关闭:
|
||||||
|
// RUNNING / INVITING 阶段对比 session.room;INCOMING 阶段对比 incomingPayload.room
|
||||||
|
const matchSession = rtcStore.session?.room === payload.room
|
||||||
|
const matchIncoming = rtcStore.incomingPayload?.room === payload.room
|
||||||
|
if (rtcStore.isActive && (matchSession || matchIncoming)) {
|
||||||
|
const reasonText = resolveCallEndReasonText(payload.endReason)
|
||||||
|
console.info('[Call] end:', reasonText)
|
||||||
|
rtcStore.reset()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -165,20 +165,20 @@ export function isGroupConversation(type: number | undefined): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** IM 通话媒体类型(对齐后端 ImRtcCallMediaTypeEnum) */
|
/** IM 通话媒体类型(对齐后端 ImRtcCallMediaTypeEnum) */
|
||||||
export const ImCallMediaType = {
|
export const ImRtcCallMediaType = {
|
||||||
VOICE: 1,
|
VOICE: 1,
|
||||||
VIDEO: 2
|
VIDEO: 2
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** IM 通话状态(对齐后端 ImRtcCallStatusEnum) */
|
/** IM 通话状态(对齐后端 ImRtcCallStatusEnum) */
|
||||||
export const ImCallStatus = {
|
export const ImRtcCallStatus = {
|
||||||
CREATED: 10, // 创建:私聊等被叫接听;群聊发起人已进房等其他人加入
|
CREATED: 10, // 创建:私聊等被叫接听;群聊发起人已进房等其他人加入
|
||||||
RUNNING: 20, // 进行中:第一个非发起人接通后进入
|
RUNNING: 20, // 进行中:第一个非发起人接通后进入
|
||||||
ENDED: 30 // 已结束
|
ENDED: 30 // 已结束
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** IM 通话结束原因(对齐后端 ImRtcCallEndReasonEnum) */
|
/** IM 通话结束原因(对齐后端 ImRtcCallEndReasonEnum) */
|
||||||
export const ImCallEndReason = {
|
export const ImRtcCallEndReason = {
|
||||||
HANGUP: 1, // 接通后任一方主动挂断
|
HANGUP: 1, // 接通后任一方主动挂断
|
||||||
REJECT: 2, // 被叫接通前点拒接
|
REJECT: 2, // 被叫接通前点拒接
|
||||||
CANCEL: 3, // 主叫接通前主动取消
|
CANCEL: 3, // 主叫接通前主动取消
|
||||||
|
|
@ -186,11 +186,11 @@ export const ImCallEndReason = {
|
||||||
ERROR: 9 // 网络中断 / 设备失败
|
ERROR: 9 // 网络中断 / 设备失败
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** ImCallEndReason 取值类型 */
|
/** ImRtcCallEndReason 取值类型 */
|
||||||
export type ImCallEndReasonValue = (typeof ImCallEndReason)[keyof typeof ImCallEndReason]
|
export type ImRtcCallEndReasonValue = (typeof ImRtcCallEndReason)[keyof typeof ImRtcCallEndReason]
|
||||||
|
|
||||||
/** IM 通话参与者状态(对齐后端 ImRtcParticipantStatusEnum);同时作为 RTC_CALL 信令 status 字段取值 */
|
/** IM 通话参与者状态(对齐后端 ImRtcParticipantStatusEnum);同时作为 RTC_CALL 信令 status 字段取值 */
|
||||||
export const ImCallParticipantStatus = {
|
export const ImRtcParticipantStatus = {
|
||||||
INVITING: 10, // 来电邀请
|
INVITING: 10, // 来电邀请
|
||||||
JOINED: 20, // 接听 / 已加入
|
JOINED: 20, // 接听 / 已加入
|
||||||
REJECTED: 30, // 拒接
|
REJECTED: 30, // 拒接
|
||||||
|
|
@ -198,9 +198,23 @@ export const ImCallParticipantStatus = {
|
||||||
LEFT: 50 // 挂断离开
|
LEFT: 50 // 挂断离开
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** ImCallParticipantStatus 取值类型 */
|
/** ImRtcParticipantStatus 取值类型 */
|
||||||
export type ImCallParticipantStatusValue =
|
export type ImRtcParticipantStatusValue =
|
||||||
(typeof ImCallParticipantStatus)[keyof typeof ImCallParticipantStatus]
|
(typeof ImRtcParticipantStatus)[keyof typeof ImRtcParticipantStatus]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IM 通话 UI 阶段;前端独有,用于驱动 inviting / incoming / running 三种弹窗切换;
|
||||||
|
* 跟后端 ImRtcCallStatus 不是 1:1 映射,stage 多了「自己是主叫还是被叫」的角色维度
|
||||||
|
*/
|
||||||
|
export const ImRtcCallStage = {
|
||||||
|
IDLE: 'idle', // 空闲;后端无对应(本端无 session)
|
||||||
|
INVITING: 'inviting', // 主叫等待对方接受;对应后端 ImRtcCallStatus.CREATED(自己是主叫)
|
||||||
|
INCOMING: 'incoming', // 被叫来电响铃;对应后端 ImRtcCallStatus.CREATED(自己是被叫)
|
||||||
|
RUNNING: 'running' // 通话中;对应后端 ImRtcCallStatus.RUNNING
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** ImRtcCallStage 取值类型 */
|
||||||
|
export type ImRtcCallStageValue = (typeof ImRtcCallStage)[keyof typeof ImRtcCallStage]
|
||||||
|
|
||||||
/** IM WebSocket 外层帧类型(对齐后端 ImPrivateMessageDTO.TYPE / ImGroupMessageDTO.TYPE) */
|
/** IM WebSocket 外层帧类型(对齐后端 ImPrivateMessageDTO.TYPE / ImGroupMessageDTO.TYPE) */
|
||||||
export const ImWebSocketMessageType = {
|
export const ImWebSocketMessageType = {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { generateUUID } from '@/utils'
|
import { generateUUID } from '@/utils'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import {
|
import {
|
||||||
ImCallEndReason,
|
ImRtcCallEndReason,
|
||||||
ImConversationType,
|
ImConversationType,
|
||||||
ImMessageType,
|
ImMessageType,
|
||||||
type ImConversationTypeValue
|
type ImConversationTypeValue
|
||||||
|
|
@ -905,15 +905,15 @@ export function resolveRtcCallPrivateBubbleText(payload: RtcCallEndPayload | nul
|
||||||
const hasDuration = duration > 0
|
const hasDuration = duration > 0
|
||||||
const isOperator = payload.operatorUserId === getCurrentUserId()
|
const isOperator = payload.operatorUserId === getCurrentUserId()
|
||||||
switch (payload.endReason) {
|
switch (payload.endReason) {
|
||||||
case ImCallEndReason.HANGUP:
|
case ImRtcCallEndReason.HANGUP:
|
||||||
return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话中断'
|
return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话中断'
|
||||||
case ImCallEndReason.CANCEL:
|
case ImRtcCallEndReason.CANCEL:
|
||||||
return isOperator ? '已取消' : '对方已取消'
|
return isOperator ? '已取消' : '对方已取消'
|
||||||
case ImCallEndReason.REJECT:
|
case ImRtcCallEndReason.REJECT:
|
||||||
return isOperator ? '已拒绝' : '对方已拒绝'
|
return isOperator ? '已拒绝' : '对方已拒绝'
|
||||||
case ImCallEndReason.BUSY:
|
case ImRtcCallEndReason.BUSY:
|
||||||
return isOperator ? '忙线未接听' : '对方忙线中'
|
return isOperator ? '忙线未接听' : '对方忙线中'
|
||||||
case ImCallEndReason.ERROR:
|
case ImRtcCallEndReason.ERROR:
|
||||||
return hasDuration ? `通话中断 ${formatCallDuration(duration)}` : '通话中断'
|
return hasDuration ? `通话中断 ${formatCallDuration(duration)}` : '通话中断'
|
||||||
default:
|
default:
|
||||||
return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话已结束'
|
return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话已结束'
|
||||||
|
|
@ -936,15 +936,15 @@ export function resolveRtcCallTipText(message: {
|
||||||
*/
|
*/
|
||||||
export function resolveCallEndReasonText(reason: number | undefined): string {
|
export function resolveCallEndReasonText(reason: number | undefined): string {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case ImCallEndReason.REJECT:
|
case ImRtcCallEndReason.REJECT:
|
||||||
return '对方已拒绝'
|
return '对方已拒绝'
|
||||||
case ImCallEndReason.CANCEL:
|
case ImRtcCallEndReason.CANCEL:
|
||||||
return '对方已取消'
|
return '对方已取消'
|
||||||
case ImCallEndReason.BUSY:
|
case ImRtcCallEndReason.BUSY:
|
||||||
return '对方忙线中'
|
return '对方忙线中'
|
||||||
case ImCallEndReason.HANGUP:
|
case ImRtcCallEndReason.HANGUP:
|
||||||
return '通话已结束'
|
return '通话已结束'
|
||||||
case ImCallEndReason.ERROR:
|
case ImRtcCallEndReason.ERROR:
|
||||||
return '通话异常'
|
return '通话异常'
|
||||||
default:
|
default:
|
||||||
return '通话已断开'
|
return '通话已断开'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue