From e579a4de13cc7c780c6b905a637a08db8f0e247f Mon Sep 17 00:00:00 2001 From: YunaiV Date: Thu, 14 May 2026 09:44:39 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E4=BC=98=E5=8C=96=20rt?= =?UTF-8?q?c=20=E6=95=B4=E4=BD=93=E5=BC=B9=E7=AA=97=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/im/home/index.vue | 4 + .../components/message/MessageItem.vue | 15 +- .../components/message/MessagePanel.vue | 184 ++++++++++++++++-- .../components/message/forward/keys.ts | 4 + src/views/im/home/store/websocketStore.ts | 116 ++++++++++- src/views/im/utils/constants.ts | 32 ++- src/views/im/utils/message.ts | 22 +-- 7 files changed, 337 insertions(+), 40 deletions(-) diff --git a/src/views/im/home/index.vue b/src/views/im/home/index.vue index ff9bff657..a852ddde0 100644 --- a/src/views/im/home/index.vue +++ b/src/views/im/home/index.vue @@ -22,6 +22,9 @@ + + + @@ -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' }) diff --git a/src/views/im/home/pages/conversation/components/message/MessageItem.vue b/src/views/im/home/pages/conversation/components/message/MessageItem.vue index 3d7b02419..d0836dde3 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageItem.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageItem.vue @@ -44,12 +44,13 @@ :size="36" />
{{ rtcCallPrivateBubbleText }} @@ -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() diff --git a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue index 68bae80e4..0bdc1fd1a 100644 --- a/src/views/im/home/pages/conversation/components/message/MessagePanel.vue +++ b/src/views/im/home/pages/conversation/components/message/MessagePanel.vue @@ -36,13 +36,45 @@ @click="historyDialogRef?.open()" /> - - + + + +
+
+ + 语音通话 +
+
+ + 视频通话 +
+
+
+ @@ -57,6 +89,12 @@
+ + + 对方还不是你的朋友 - + @@ -132,10 +166,7 @@
- +
@@ -165,6 +196,9 @@ + + +
() // ==================== 转发 / 合并消息详情:本地 dialog 浮层 ==================== @@ -220,6 +264,11 @@ const mergeDetailDialogRef = ref>( 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>() const sideVisible = ref(false) // 信息抽屉开关:群聊 / 私聊共用一个 ref const muteMemberDialogRef = ref>() +const callMemberPickerRef = ref>() +/** 群通话发起:成员选择弹窗打开期间临时持有的 mediaType */ +const pendingMediaType = ref(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); +} + + + diff --git a/src/views/im/home/pages/conversation/components/message/forward/keys.ts b/src/views/im/home/pages/conversation/components/message/forward/keys.ts index 76aa8f795..4a7dd9551 100644 --- a/src/views/im/home/pages/conversation/components/message/forward/keys.ts +++ b/src/views/im/home/pages/conversation/components/message/forward/keys.ts @@ -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 = Symbol('IM_FORWARD_DIALOG') export const IM_MERGE_DETAIL_DIALOG_KEY: InjectionKey = Symbol( 'IM_MERGE_DETAIL_DIALOG' ) +export const IM_RTC_REDIAL_KEY: InjectionKey = Symbol('IM_RTC_REDIAL') diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index dca5c7989..17792f001 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -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 参与者加入 / 离开 + *

+ * 单一 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 区分 + *

+ * 私聊:关闭当前通话窗 + * 群聊:移除胶囊条;如本端在该群通话内则关闭通话窗 + */ + 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() + } } } }) diff --git a/src/views/im/utils/constants.ts b/src/views/im/utils/constants.ts index 09e94a8e0..af1c5b2e3 100644 --- a/src/views/im/utils/constants.ts +++ b/src/views/im/utils/constants.ts @@ -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 = { diff --git a/src/views/im/utils/message.ts b/src/views/im/utils/message.ts index 0b9f3a043..ff618f182 100644 --- a/src/views/im/utils/message.ts +++ b/src/views/im/utils/message.ts @@ -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 '通话已断开'