import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { isEqual, union } from 'lodash-es' import type { ImRtcCallRespVO, ImRtcGroupCallRespVO } from '@/api/im/rtc' import { ImRtcCallStage, ImRtcCallStatus, ImConversationType, type ImRtcCallEndReasonValue, type ImRtcParticipantStatusValue, type ImRtcCallStageValue } from '../../utils/constants' import { getCurrentUserId } from '../../utils/storage' import { useFriendStore } from './friendStore' import { useGroupStore } from './groupStore' // RTC_CALL 通话信令载荷;按 status 区分子类型语义 export interface ImRtcCallNotification { status: ImRtcParticipantStatusValue room: string conversationType: number mediaType: number groupId?: number // INVITE 专属:被叫接通需要的 LiveKit 连接参数 + 主叫展示信息 livekitUrl?: string token?: string inviterUserId?: number inviterNickname?: string inviterAvatar?: string // INVITE 专属:本次被邀请人列表;包含收件人自身,前端来电小条按需过滤展示「邀请的其他人」 inviteeIds?: number[] // REJECT 专属:操作者展示信息(其它子类型走 RTC_CALL_END) operatorUserId?: number operatorNickname?: string operatorAvatar?: string } // RTC_PARTICIPANT_CONNECTED 通话参与者加入载荷(LiveKit webhook 转推) export interface ImRtcParticipantConnectedNotification { room: string userId: number conversationType: number groupId?: number // 群聊场景非邀请成员首次填充胶囊条用 mediaType?: number inviterUserId?: number } // RTC_PARTICIPANT_DISCONNECTED 通话参与者离开载荷(LiveKit webhook 转推) export interface ImRtcParticipantDisconnectedNotification { room: string userId: number conversationType: number groupId?: number } // RTC_CALL_END 通话结束载荷(入消息流;私聊渲染消息气泡,群聊渲染系统提示行) export interface ImRtcCallEndNotification { room: string conversationType: number mediaType: number endReason: ImRtcCallEndReasonValue durationSeconds?: number // 操作者聚合字段:HANGUP/CANCEL/REJECT 触发人;webhook 兜底为 null operatorUserId?: number operatorNickname?: string operatorAvatar?: string } export const useRtcStore = defineStore('imRtc', () => { /** 当前阶段 */ const stage = ref(ImRtcCallStage.IDLE) /** 当前通话;invite / accept / refreshToken 拿到的完整信息 */ const call = ref(null) /** 来电载荷;仅 INCOMING 阶段使用;status 固定 INVITING,其它字段 INVITE 专属 */ const incomingPayload = ref(null) /** 进入 RUNNING 的时间戳;用于通话时长展示;reset 时清零 */ const startedAt = ref(0) /** 是否处于通话相关阶段 */ const isActive = computed(() => stage.value !== ImRtcCallStage.IDLE) /** * 对端展示名;按阶段 + 会话类型分支: * INCOMING 取来电载荷的 inviterNickname;群通话取群名;私聊查 friendStore 反查对端 userId */ const peerNickname = computed(() => { if (stage.value === ImRtcCallStage.INCOMING) { return incomingPayload.value?.inviterNickname || '' } const c = call.value if (!c) return '' if (c.conversationType === ImConversationType.GROUP) { return useGroupStore().getGroup(c.groupId ?? 0)?.name || '' } const peerUserId = resolvePrivatePeerUserId(c) return (peerUserId && useFriendStore().getFriend(peerUserId)?.nickname) || '' }) /** 对端头像;策略同 peerNickname */ const peerAvatar = computed(() => { if (stage.value === ImRtcCallStage.INCOMING) { return incomingPayload.value?.inviterAvatar || '' } const c = call.value if (!c) return '' if (c.conversationType === ImConversationType.GROUP) { return useGroupStore().getGroup(c.groupId ?? 0)?.avatar || '' } const peerUserId = resolvePrivatePeerUserId(c) return (peerUserId && useFriendStore().getFriend(peerUserId)?.avatar) || '' }) /** 私聊场景对端 userId:自己是主叫则取首个 invitee,否则取 inviter */ function resolvePrivatePeerUserId(c: ImRtcCallRespVO): number | undefined { const myId = getCurrentUserId() return c.inviterId === myId ? c.inviteeIds?.[0] : c.inviterId } /** 群活跃通话索引;groupId -> 群通话摘要;用于群聊顶部胶囊条 */ const groupActiveCalls = ref>(new Map()) /** * 已退出 / 已拒绝的用户编号集合;群通话场景内 pending 占位渲染时排除; * 来源:参与者离开通知 + 群通话单人拒绝的 operatorUserId;通话结束(reset)时清空 */ const leftUserIds = ref>(new Set()) /** 是否已记录某 userId 已退出 / 拒绝 */ function isUserLeft(userId: number): boolean { return leftUserIds.value.has(userId) } /** 标记某个 userId 已退出 / 拒绝;用于 pending 占位渲染时排除 */ function markUserLeft(userId: number) { if (!userId || leftUserIds.value.has(userId)) { return } leftUserIds.value = new Set([...leftUserIds.value, userId]) } /** * 主叫发起通话;按会话类型 + status 决定 stage; * 群通话:发起人直接进 RUNNING 多人卡片视图,房内可能只有自己,等其他人陆续加入; * 私聊:按 status 走;RUNNING(已加入已有通话场景)→ RUNNING;CREATED → INVITING 等被叫接通 */ function startInviting(data: ImRtcCallRespVO) { call.value = data // 群通话场景写入本地胶囊条缓存 syncGroupActiveCall(data) // 更新 stage 状态 if (data.conversationType === ImConversationType.GROUP) { stage.value = ImRtcCallStage.RUNNING startedAt.value = Date.now() return } const running = data.status === ImRtcCallStatus.RUNNING stage.value = running ? ImRtcCallStage.RUNNING : ImRtcCallStage.INVITING if (running) { startedAt.value = Date.now() } } /** 被叫收到来电;切到 INCOMING;接收 RTC_CALL(INVITE) payload */ function showIncoming(payload: ImRtcCallNotification) { incomingPayload.value = payload stage.value = ImRtcCallStage.INCOMING // 按 inviter 兜底首次填充胶囊条 syncGroupActiveCall({ conversationType: payload.conversationType, room: payload.room, groupId: payload.groupId, mediaType: payload.mediaType, inviterId: payload.inviterUserId ?? 0, joinedUserIds: payload.inviterUserId ? [payload.inviterUserId] : [], inviteeIds: payload.inviteeIds }) } /** 进入通话中阶段 */ function enterRunning(data: ImRtcCallRespVO) { call.value = data // 离开 INCOMING 阶段;清空来电载荷 incomingPayload.value = null stage.value = ImRtcCallStage.RUNNING startedAt.value = Date.now() // 接通后用 RespVO 完整覆盖胶囊条 syncGroupActiveCall(data) } /** * 群通话场景同步本地 groupActiveCalls 缓存;非群通话或缺 groupId 直接返回; * 不依赖后端 webhook 推送的 RTC_PARTICIPANT_CONNECTED 首次填充,避免胶囊条出现延迟; * 被叫场景通知载荷无 joinedUserIds,调用方按主叫人兜底,后续 getActiveCall / 参与者事件刷新成完整列表 */ function syncGroupActiveCall(input: { conversationType: number room: string groupId?: number mediaType: number inviterId: number joinedUserIds?: number[] inviteeIds?: number[] }) { if (input.conversationType !== ImConversationType.GROUP || !input.groupId) { return } // 写入或更新群活跃通话缓存 setGroupCall({ room: input.room, groupId: input.groupId, mediaType: input.mediaType, inviterId: input.inviterId, joinedUserIds: input.joinedUserIds ?? [], inviteeIds: input.inviteeIds ?? [] }) } /** 重置;通话结束统一调用 */ function reset() { stage.value = ImRtcCallStage.IDLE call.value = null incomingPayload.value = null startedAt.value = 0 leftUserIds.value = new Set() } /** 通话中追加被邀请人;让 participants 网格出现 pending 占位、胶囊条同步更新 */ function appendInvitees(userIds: number[]) { if (!call.value || userIds.length === 0) { return } const existing = call.value.inviteeIds ?? [] const merged = union(existing, userIds) if (merged.length === existing.length) { return } call.value = { ...call.value, inviteeIds: merged } syncGroupActiveCall(call.value) } // ==================== 群通话胶囊条状态 ==================== /** * 群通话开始 / 状态刷新:写入 / 更新 groupActiveCalls;展示用「胶囊条」; * 房内成员同步交给 LiveKit 客户端事件(ParticipantConnected / Disconnected); * 胶囊条不实时刷新 joinedUserIds / inviteeIds,展开 / 加入时再走 getActiveCall 接口拉最新 */ function setGroupCall(payload: ImRtcGroupCallRespVO) { if (!payload?.groupId) { return } // 浅比较:room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算 const existing = groupActiveCalls.value.get(payload.groupId) if (existing && isSameGroupCall(existing, payload)) { return } const newGroupActiveCalls = new Map(groupActiveCalls.value) newGroupActiveCalls.set(payload.groupId, payload) groupActiveCalls.value = newGroupActiveCalls } /** 两条群通话摘要内容相等(room / mediaType / inviterId / 两个 userId 数组逐项相等) */ function isSameGroupCall(a: ImRtcGroupCallRespVO, b: ImRtcGroupCallRespVO): boolean { if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) { return false } return isEqual(a.joinedUserIds ?? [], b.joinedUserIds ?? []) && isEqual(a.inviteeIds ?? [], b.inviteeIds ?? []) } /** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */ function removeGroupCall(groupId: number) { if (!groupId || !groupActiveCalls.value.has(groupId)) { return } const newGroupActiveCalls = new Map(groupActiveCalls.value) newGroupActiveCalls.delete(groupId) groupActiveCalls.value = newGroupActiveCalls } /** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */ function getGroupCall(groupId: number): ImRtcGroupCallRespVO | undefined { return groupActiveCalls.value.get(groupId) } /** 通话参与者加入:把 userId 加进 joinedUserIds;群聊场景无活跃记录时首次填充胶囊条 */ function applyParticipantConnected(payload: ImRtcParticipantConnectedNotification) { const isGroup = payload.conversationType === ImConversationType.GROUP if (!isGroup || !payload.groupId) { return } // 胶囊条懒填充:本端可能在通话开始后才打开该群会话,没收到过 setGroupCall; // 此处用加入通知建一条最小记录,inviteeIds 留空,展开 popover / 加入时再走 getActiveCall 补 const existing = groupActiveCalls.value.get(payload.groupId) if (!existing) { setGroupCall({ room: payload.room, groupId: payload.groupId, mediaType: payload.mediaType ?? 0, inviterId: payload.inviterUserId ?? 0, joinedUserIds: [payload.userId], inviteeIds: [] }) return } if (existing.room !== payload.room) { return } const joined = existing.joinedUserIds ?? [] if (joined.includes(payload.userId)) { return } setGroupCall({ ...existing, joinedUserIds: [...joined, payload.userId] }) } /** 通话参与者离开:从 joinedUserIds 移除;同时标记 leftUserIds(pending 占位渲染排除) */ function applyParticipantDisconnected(payload: ImRtcParticipantDisconnectedNotification) { markUserLeft(payload.userId) const isGroup = payload.conversationType === ImConversationType.GROUP if (!isGroup || !payload.groupId) { return } dropFromGroupActiveCall(payload.groupId, payload.room, payload.userId) } /** 群通话单人拒绝邀请:标记 leftUserIds + 从胶囊条 inviteeIds 移除(私聊拒绝走 RTC_CALL_END,不入本通道) */ function applyParticipantRejected( payload: Pick ) { if (!payload.operatorUserId) { return } markUserLeft(payload.operatorUserId) if (payload.conversationType === ImConversationType.GROUP && payload.groupId) { dropFromGroupActiveCall(payload.groupId, payload.room, payload.operatorUserId) } } /** 群通话单人振铃超时;对 banner 的处理与拒接一致(语义独立、实现共享) */ function applyParticipantNoAnswer( payload: Pick ) { applyParticipantRejected(payload) } /** 从指定群活跃通话的 joined / pending 列表里同步移除某用户;用于 disconnect / reject 让胶囊条不再展示 */ function dropFromGroupActiveCall(groupId: number, room: string, userId: number) { const existing = groupActiveCalls.value.get(groupId) if (!existing || existing.room !== room) { return } const joined = existing.joinedUserIds ?? [] const invitee = existing.inviteeIds ?? [] const nextJoined = joined.filter((id) => id !== userId) const nextInvitee = invitee.filter((id) => id !== userId) if (nextJoined.length === joined.length && nextInvitee.length === invitee.length) { return } setGroupCall({ ...existing, joinedUserIds: nextJoined, inviteeIds: nextInvitee }) } return { stage, call, incomingPayload, peerNickname, peerAvatar, startedAt, isActive, startInviting, showIncoming, enterRunning, reset, appendInvitees, markUserLeft, isUserLeft, setGroupCall, removeGroupCall, getGroupCall, applyParticipantConnected, applyParticipantDisconnected, applyParticipantRejected, applyParticipantNoAnswer } })