✨ feat(im): 优化 rtc 整体弹窗界面
parent
a170ae37ab
commit
e579a4de13
|
|
@ -22,6 +22,9 @@
|
|||
<UserInfoCard />
|
||||
<GroupInfoCard />
|
||||
<ContextMenu />
|
||||
|
||||
<!-- 实时通话浮层;监听 rtcStore 全局状态,可在任意 IM 子页弹出 -->
|
||||
<CallContainer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -47,6 +50,7 @@ import ToolBar from './components/ToolBar.vue'
|
|||
import UserInfoCard from './components/user/UserInfoCard.vue'
|
||||
import GroupInfoCard from './components/group/GroupInfoCard.vue'
|
||||
import ContextMenu from './components/ContextMenu.vue'
|
||||
import CallContainer from './components/rtc/CallContainer.vue'
|
||||
|
||||
defineOptions({ name: 'ImIndex' })
|
||||
|
||||
|
|
|
|||
|
|
@ -44,12 +44,13 @@
|
|||
:size="36"
|
||||
/>
|
||||
<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="
|
||||
message.selfSend
|
||||
? 'text-black bg-[#95ec69]'
|
||||
: '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" />
|
||||
<span class="whitespace-nowrap">{{ rtcCallPrivateBubbleText }}</span>
|
||||
|
|
@ -251,7 +252,7 @@ import ReplyPreview from './ReplyPreview.vue'
|
|||
import TipSegments from './TipSegments.vue'
|
||||
import UserAvatar from '../../../../components/user/UserAvatar.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 type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
||||
|
||||
|
|
@ -376,6 +377,16 @@ const quote = computed(() => getQuoteFromMessage(props.message.content))
|
|||
/** MessagePanel 注入的弹窗触发函数 */
|
||||
const openForwardDialog = inject(IM_FORWARD_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 */
|
||||
const multiSelect = useMessageMultiSelect()
|
||||
|
|
|
|||
|
|
@ -36,13 +36,45 @@
|
|||
@click="historyDialogRef?.open()"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<!-- 通话入口:暂未开放,先放占位图标对齐微信 PC -->
|
||||
<el-tooltip content="通话" placement="bottom">
|
||||
<!-- 通话入口:私聊弹「语音 / 视频」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="handleCall"
|
||||
@click="handleGroupCall"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<!-- 信息抽屉入口 -->
|
||||
|
|
@ -57,6 +89,12 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 群通话胶囊条:仅群聊 + 该群有活跃通话时显示;点击展开看成员 + 加入按钮 -->
|
||||
<GroupCallBanner
|
||||
v-if="isGroup && conversationStore.activeConversation"
|
||||
:group-id="conversationStore.activeConversation.targetId"
|
||||
/>
|
||||
|
||||
<!-- 群置顶消息:第二行嵌入 header;仅群聊 + 有置顶时显示 -->
|
||||
<GroupPinnedMessage
|
||||
v-if="isGroup && conversationStore.activeConversation"
|
||||
|
|
@ -75,11 +113,7 @@
|
|||
<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)]"
|
||||
/>
|
||||
<Icon icon="ep:arrow-right" :size="12" class="text-[var(--el-text-color-secondary)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -132,10 +166,7 @@
|
|||
<!-- 底部:输入框常驻;多选模式底栏作为浮层盖在上面,保持下方输入框尺寸不变 -->
|
||||
<div class="relative">
|
||||
<MessageInput />
|
||||
<MessageMultiSelectBar
|
||||
v-if="multiSelect.state.active"
|
||||
class="absolute inset-0 z-10"
|
||||
/>
|
||||
<MessageMultiSelectBar v-if="multiSelect.state.active" class="absolute inset-0 z-10" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧信息抽屉:群聊 / 私聊各自一份 -->
|
||||
|
|
@ -165,6 +196,9 @@
|
|||
|
||||
<!-- 禁言时长选择弹窗 -->
|
||||
<GroupMuteMemberDialog ref="muteMemberDialogRef" @success="reloadGroupData" />
|
||||
|
||||
<!-- 群通话成员选择弹窗 -->
|
||||
<CallMemberPickerDialog ref="callMemberPickerRef" @success="onCallMemberPicked" />
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
|
|
@ -185,13 +219,16 @@ import { useFriendStore } from '../../../../store/friendStore'
|
|||
import { useImUiStore } from '../../../../store/uiStore'
|
||||
import { getMemberDisplayName } from '@/views/im/utils/user'
|
||||
import { useGroupStore } from '../../../../store/groupStore'
|
||||
import { ImConversationType } from '@/views/im/utils/constants'
|
||||
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 } 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 { useVoicePlayer } from '../../../../composables/useVoicePlayer'
|
||||
import MessageHistory from './MessageHistory.vue'
|
||||
|
|
@ -202,6 +239,12 @@ 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 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' })
|
||||
|
||||
|
|
@ -210,6 +253,7 @@ const friendStore = useFriendStore()
|
|||
const uiStore = useImUiStore()
|
||||
const groupStore = useGroupStore()
|
||||
const message = useMessage()
|
||||
const rtcStore = useRtcStore()
|
||||
const listRef = ref<HTMLElement>()
|
||||
|
||||
// ==================== 转发 / 合并消息详情:本地 dialog 浮层 ====================
|
||||
|
|
@ -220,6 +264,11 @@ 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 显隐判定
|
||||
|
|
@ -243,6 +292,9 @@ 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(() => {
|
||||
|
|
@ -383,6 +435,9 @@ function reloadGroupData() {
|
|||
const historyDialogRef = ref<InstanceType<typeof MessageHistory>>()
|
||||
const sideVisible = ref(false) // 信息抽屉开关:群聊 / 私聊共用一个 ref
|
||||
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) {
|
||||
|
|
@ -394,9 +449,75 @@ function toggleSide() {
|
|||
sideVisible.value = !sideVisible.value
|
||||
}
|
||||
|
||||
/** 通话入口:功能未开放,先弹提示占位 */
|
||||
function handleCall() {
|
||||
message.warning('通话功能暂未开放')
|
||||
/** 私聊通话入口: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]
|
||||
},
|
||||
{ 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;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -13,8 +13,12 @@ export type OpenForwardDialog = (opts: {
|
|||
/** 打开合并消息详情弹窗 */
|
||||
export type OpenMergeDetailDialog = (content: string) => void
|
||||
|
||||
/** 重拨 RTC 通话;点私聊 RTC_CALL_END 气泡触发 */
|
||||
export type RtcRedial = (mediaType: number) => void
|
||||
|
||||
/** MessagePanel 通过 provide 暴露给子树 */
|
||||
export const IM_FORWARD_DIALOG_KEY: InjectionKey<OpenForwardDialog> = Symbol('IM_FORWARD_DIALOG')
|
||||
export const IM_MERGE_DETAIL_DIALOG_KEY: InjectionKey<OpenMergeDetailDialog> = Symbol(
|
||||
'IM_MERGE_DETAIL_DIALOG'
|
||||
)
|
||||
export const IM_RTC_REDIAL_KEY: InjectionKey<RtcRedial> = Symbol('IM_RTC_REDIAL')
|
||||
|
|
|
|||
|
|
@ -7,18 +7,26 @@ import {
|
|||
ImWebSocketMessageType,
|
||||
ImMessageType,
|
||||
ImConversationType,
|
||||
ImRtcParticipantStatus,
|
||||
isFriendChatTip,
|
||||
isFriendNotification,
|
||||
isGroupRequestNotification,
|
||||
isNormalMessage
|
||||
} 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 { useConversationStore } from './conversationStore'
|
||||
import { useFriendStore, type FriendNotificationPayload } from './friendStore'
|
||||
import { getFriendDisplayName } from '../../utils/user'
|
||||
import { useGroupStore } from './groupStore'
|
||||
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 { readGroupMessages as apiReadGroupMessages } from '@/api/im/home/message/group'
|
||||
import type {
|
||||
|
|
@ -238,6 +246,16 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
case ImMessageType.RECEIPT:
|
||||
this.handlePrivateReceipt(websocketMessage)
|
||||
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:
|
||||
if (isFriendNotification(websocketMessage.type)) {
|
||||
this.handleFriendNotification(websocketMessage)
|
||||
|
|
@ -282,6 +300,15 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
case ImMessageType.GROUP_MEMBER_SETTING_UPDATE:
|
||||
this.handleGroupMemberSettingUpdate(websocketMessage)
|
||||
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:
|
||||
// TEXT / IMAGE / FILE / VOICE / VIDEO + GROUP_* 群广播事件
|
||||
this.handleGroupMessage(websocketMessage)
|
||||
|
|
@ -689,6 +716,93 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
clearInterval(this.heartbeatTimer)
|
||||
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) */
|
||||
export const ImCallMediaType = {
|
||||
export const ImRtcCallMediaType = {
|
||||
VOICE: 1,
|
||||
VIDEO: 2
|
||||
} as const
|
||||
|
||||
/** IM 通话状态(对齐后端 ImRtcCallStatusEnum) */
|
||||
export const ImCallStatus = {
|
||||
export const ImRtcCallStatus = {
|
||||
CREATED: 10, // 创建:私聊等被叫接听;群聊发起人已进房等其他人加入
|
||||
RUNNING: 20, // 进行中:第一个非发起人接通后进入
|
||||
ENDED: 30 // 已结束
|
||||
} as const
|
||||
|
||||
/** IM 通话结束原因(对齐后端 ImRtcCallEndReasonEnum) */
|
||||
export const ImCallEndReason = {
|
||||
export const ImRtcCallEndReason = {
|
||||
HANGUP: 1, // 接通后任一方主动挂断
|
||||
REJECT: 2, // 被叫接通前点拒接
|
||||
CANCEL: 3, // 主叫接通前主动取消
|
||||
|
|
@ -186,11 +186,11 @@ export const ImCallEndReason = {
|
|||
ERROR: 9 // 网络中断 / 设备失败
|
||||
} as const
|
||||
|
||||
/** ImCallEndReason 取值类型 */
|
||||
export type ImCallEndReasonValue = (typeof ImCallEndReason)[keyof typeof ImCallEndReason]
|
||||
/** ImRtcCallEndReason 取值类型 */
|
||||
export type ImRtcCallEndReasonValue = (typeof ImRtcCallEndReason)[keyof typeof ImRtcCallEndReason]
|
||||
|
||||
/** IM 通话参与者状态(对齐后端 ImRtcParticipantStatusEnum);同时作为 RTC_CALL 信令 status 字段取值 */
|
||||
export const ImCallParticipantStatus = {
|
||||
export const ImRtcParticipantStatus = {
|
||||
INVITING: 10, // 来电邀请
|
||||
JOINED: 20, // 接听 / 已加入
|
||||
REJECTED: 30, // 拒接
|
||||
|
|
@ -198,9 +198,23 @@ export const ImCallParticipantStatus = {
|
|||
LEFT: 50 // 挂断离开
|
||||
} as const
|
||||
|
||||
/** ImCallParticipantStatus 取值类型 */
|
||||
export type ImCallParticipantStatusValue =
|
||||
(typeof ImCallParticipantStatus)[keyof typeof ImCallParticipantStatus]
|
||||
/** ImRtcParticipantStatus 取值类型 */
|
||||
export type ImRtcParticipantStatusValue =
|
||||
(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) */
|
||||
export const ImWebSocketMessageType = {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { generateUUID } from '@/utils'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import {
|
||||
ImCallEndReason,
|
||||
ImRtcCallEndReason,
|
||||
ImConversationType,
|
||||
ImMessageType,
|
||||
type ImConversationTypeValue
|
||||
|
|
@ -905,15 +905,15 @@ export function resolveRtcCallPrivateBubbleText(payload: RtcCallEndPayload | nul
|
|||
const hasDuration = duration > 0
|
||||
const isOperator = payload.operatorUserId === getCurrentUserId()
|
||||
switch (payload.endReason) {
|
||||
case ImCallEndReason.HANGUP:
|
||||
case ImRtcCallEndReason.HANGUP:
|
||||
return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话中断'
|
||||
case ImCallEndReason.CANCEL:
|
||||
case ImRtcCallEndReason.CANCEL:
|
||||
return isOperator ? '已取消' : '对方已取消'
|
||||
case ImCallEndReason.REJECT:
|
||||
case ImRtcCallEndReason.REJECT:
|
||||
return isOperator ? '已拒绝' : '对方已拒绝'
|
||||
case ImCallEndReason.BUSY:
|
||||
case ImRtcCallEndReason.BUSY:
|
||||
return isOperator ? '忙线未接听' : '对方忙线中'
|
||||
case ImCallEndReason.ERROR:
|
||||
case ImRtcCallEndReason.ERROR:
|
||||
return hasDuration ? `通话中断 ${formatCallDuration(duration)}` : '通话中断'
|
||||
default:
|
||||
return hasDuration ? `通话时长 ${formatCallDuration(duration)}` : '通话已结束'
|
||||
|
|
@ -936,15 +936,15 @@ export function resolveRtcCallTipText(message: {
|
|||
*/
|
||||
export function resolveCallEndReasonText(reason: number | undefined): string {
|
||||
switch (reason) {
|
||||
case ImCallEndReason.REJECT:
|
||||
case ImRtcCallEndReason.REJECT:
|
||||
return '对方已拒绝'
|
||||
case ImCallEndReason.CANCEL:
|
||||
case ImRtcCallEndReason.CANCEL:
|
||||
return '对方已取消'
|
||||
case ImCallEndReason.BUSY:
|
||||
case ImRtcCallEndReason.BUSY:
|
||||
return '对方忙线中'
|
||||
case ImCallEndReason.HANGUP:
|
||||
case ImRtcCallEndReason.HANGUP:
|
||||
return '通话已结束'
|
||||
case ImCallEndReason.ERROR:
|
||||
case ImRtcCallEndReason.ERROR:
|
||||
return '通话异常'
|
||||
default:
|
||||
return '通话已断开'
|
||||
|
|
|
|||
Loading…
Reference in New Issue