feat(im):优化已读上报、群详情缓存与 RTC 通话状态
- 已读上报增加本地读位置覆盖判断,避免切换会话和当前会话自动已读时重复调用 read 接口 - 标记会话已读时同步推进本地 read 游标并写入 IndexedDB,接口失败仅记录日志 - 缓存私聊对方 maxReadMessageId,并在状态补拉、回执更新和退出 IM 时维护缓存 - 增加群详情 infoLoaded 内存标记,减少切群时重复拉取群详情,手动刷新和关键通知仍强制刷新 - 同步 GROUP_INFO_UPDATE 的 joinApproval,避免群审批配置在前端缓存中陈旧 - 优化群通话胶囊条状态,记录 participantsLoaded,按需补齐参与者并在通话无人时移除胶囊 - RTC_CALL_START 生成群通话最小胶囊条,后续由参与者事件和 getActiveCall 补齐 - 退出 IM 时清理 RTC 状态和群通话缓存 - Vben antd/antd-next 调整媒体元素为函数 ref,修复 MediaStream 与元素挂载时序问题 - 修复 Vben 消息历史弹窗回调类型标注im
parent
dcafe6efdc
commit
2172415cad
|
|
@ -101,15 +101,18 @@ const pillText = computed(() => {
|
|||
watch(
|
||||
() => [props.groupId, activeCall.value?.room] as const,
|
||||
async ([groupId, room], oldValues) => {
|
||||
if (!groupId) {
|
||||
if (!groupId || !activeCall.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 决策是否需要拉取:切群 / room 切换必拉;同群同 room 且已加载 >= 2 人则跳过,避免参与者通知触发后重复请求
|
||||
// 决策是否需要拉取:仅补齐本地已有通话;没有本地通话时等待实时事件创建
|
||||
const groupChanged = !oldValues || oldValues[0] !== groupId
|
||||
const roomChanged = oldValues && oldValues[1] !== room
|
||||
const hydrated = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
|
||||
if (!groupChanged && !roomChanged && hydrated) {
|
||||
const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
|
||||
if (
|
||||
rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
|
||||
(!groupChanged && !roomChanged && participantsLoaded)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +120,7 @@ watch(
|
|||
try {
|
||||
const data = await getActiveCall(groupId)
|
||||
if (data) {
|
||||
rtcStore.setGroupCall(data)
|
||||
rtcStore.setGroupCall(data, true)
|
||||
} else {
|
||||
rtcStore.removeGroupCall(groupId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useFriendStore } from '../store/friendStore'
|
|||
import { getFriendDisplayName, getGroupDisplayName } from '../../utils/user'
|
||||
import { useGroupStore } from '../store/groupStore'
|
||||
import { useGroupRequestStore } from '../store/groupRequestStore'
|
||||
import { useRtcStore } from '../store/rtcStore'
|
||||
import {
|
||||
pullPrivateMessageList as apiPullPrivateMessageList,
|
||||
getPrivateMaxReadMessageId as apiGetPrivateMaxReadMessageId,
|
||||
|
|
@ -58,6 +59,7 @@ export const useMessagePuller = () => {
|
|||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
const groupRequestStore = useGroupRequestStore()
|
||||
const rtcStore = useRtcStore()
|
||||
const currentUserId = getCurrentUserId()
|
||||
|
||||
/** 判断请求是否被主动取消 */
|
||||
|
|
@ -290,7 +292,12 @@ export const useMessagePuller = () => {
|
|||
* 群成员不做全局增量同步,重连只标记本地群成员 cache 过期,进入群会话或成员列表时再按 groupId 刷新。
|
||||
*/
|
||||
const pullStateEvents = async (): Promise<void> => {
|
||||
// 1. 清理连接级缓存
|
||||
messageStore.clearPrivateReadMaxIdCache()
|
||||
rtcStore.clearGroupCallCache()
|
||||
groupStore.markAllGroupInfoExpired()
|
||||
groupStore.markAllGroupMembersExpired()
|
||||
// 2. 并发补偿远端状态
|
||||
const results = await Promise.allSettled([
|
||||
friendStore.pullFriends(),
|
||||
friendStore.pullFriendRequests(),
|
||||
|
|
@ -408,6 +415,7 @@ export const useMessagePuller = () => {
|
|||
if (!isCurrentPull()) {
|
||||
return
|
||||
}
|
||||
messageStore.updatePrivateReadMaxId(active.targetId, maxReadId)
|
||||
if (maxReadId) {
|
||||
messageStore.applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ interface SendExtOptions {
|
|||
* 1. 私聊 / 群聊接口签名对称,按 conversation.type 分支调度,差异在分支内部消化
|
||||
* 2. 发送走「乐观更新」:先 insertMessage 写入 SENDING 占位,请求成功 ackMessage 更新为 NORMAL,失败更新为 FAILED
|
||||
* 3. 撤回不做乐观更新:服务端通过 WebSocket RECALL 事件回传,由 websocketStore 统一更新状态,避免网络失败后不可回退
|
||||
* 4. 已读上报:本端立刻清未读数;服务端回包成功后再做持久化
|
||||
* 4. 已读上报:本端立刻清未读数并记录本地读位置;接口失败仅记录日志
|
||||
*/
|
||||
export const useMessageSender = () => {
|
||||
const conversationStore = useConversationStore()
|
||||
|
|
@ -221,7 +221,7 @@ export const useMessageSender = () => {
|
|||
|
||||
/**
|
||||
* 触发当前会话的已读上报(切会话 / 进入页面时调用)
|
||||
* 1. 本端立刻清未读数;服务端回包成功后再做持久化
|
||||
* 1. 本端立刻清未读数并推进读位置
|
||||
* 2. 已读位置取已加载消息和会话末条消息的最大服务端 id
|
||||
*/
|
||||
const readActive = async () => {
|
||||
|
|
@ -237,15 +237,24 @@ export const useMessageSender = () => {
|
|||
0
|
||||
)
|
||||
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
maxMessageId
|
||||
)
|
||||
if (readCovered) {
|
||||
conversationStore.markConversationRead(conversation.type, conversation.targetId)
|
||||
return
|
||||
}
|
||||
const isPrivate = conversation.type === ImConversationType.PRIVATE
|
||||
const isGroup = conversation.type === ImConversationType.GROUP
|
||||
const isChannel = conversation.type === ImConversationType.CHANNEL
|
||||
// 本地标记已读:未读数清零(UI 立刻响应)
|
||||
conversationStore.markConversationRead(conversation.type, conversation.targetId, maxMessageId)
|
||||
if (!maxMessageId) {
|
||||
return
|
||||
}
|
||||
// 接口调用:按会话类型分发,并按对应已读开关控制;失败仅记录日志,不回退本地已读状态
|
||||
const isPrivate = conversation.type === ImConversationType.PRIVATE
|
||||
const isGroup = conversation.type === ImConversationType.GROUP
|
||||
const isChannel = conversation.type === ImConversationType.CHANNEL
|
||||
// 接口调用:按会话类型分发,并按对应已读开关控制
|
||||
if (!isPrivate && !isGroup && !isChannel) {
|
||||
return
|
||||
}
|
||||
|
|
@ -287,9 +296,21 @@ export const useMessageSender = () => {
|
|||
if (!MESSAGE_PRIVATE_READ_ENABLED) {
|
||||
return
|
||||
}
|
||||
const cachedMaxReadId = messageStore.getPrivateReadMaxId(peerId)
|
||||
if (cachedMaxReadId !== undefined) {
|
||||
if (cachedMaxReadId > 0) {
|
||||
messageStore.applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: peerId,
|
||||
privateReadMaxId: cachedMaxReadId
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 拉取对方已读到的最大消息 id
|
||||
const maxReadId = await apiGetPrivateMaxReadMessageId(peerId)
|
||||
messageStore.updatePrivateReadMaxId(peerId, maxReadId)
|
||||
if (!maxReadId) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import { useGroupStore } from './store/groupStore'
|
|||
import { useGroupRequestStore } from './store/groupRequestStore'
|
||||
import { useFaceStore } from './store/faceStore'
|
||||
import { useChannelStore } from './store/channelStore'
|
||||
import { useRtcStore } from './store/rtcStore'
|
||||
import { useMessagePuller } from './composables/useMessagePuller'
|
||||
import { useMessageSender } from './composables/useMessageSender'
|
||||
import { useVoicePlayer } from './composables/useVoicePlayer'
|
||||
|
|
@ -65,6 +66,7 @@ const groupStore = useGroupStore()
|
|||
const groupRequestStore = useGroupRequestStore()
|
||||
const faceStore = useFaceStore()
|
||||
const channelStore = useChannelStore()
|
||||
const rtcStore = useRtcStore()
|
||||
const { pullOnce, cancelPull } = useMessagePuller()
|
||||
const { readActive, syncPrivateReadStatus } = useMessageSender()
|
||||
const voicePlayer = useVoicePlayer()
|
||||
|
|
@ -173,10 +175,12 @@ function onBeforeUnload() {
|
|||
}
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
|
||||
/** 离开 IM 主壳:取消在飞的 pull + 主动断 WebSocket + flush 草稿 + 清空表情缓存 + 解绑 unload + 停语音 */
|
||||
/** 离开 IM 主壳:取消 pull、断开 WebSocket、清理 RTC、保存草稿、停止语音、解绑 unload,并结束当前 IM session */
|
||||
onUnmounted(() => {
|
||||
cancelPull()
|
||||
webSocketStore.disconnect()
|
||||
rtcStore.reset()
|
||||
rtcStore.clearGroupCallCache()
|
||||
conversationStore.flushConversationDraftSave()
|
||||
faceStore.clear()
|
||||
// 模块级单例 audio 不会随视图卸载自动停,主动停掉避免切路由后语音继续响
|
||||
|
|
|
|||
|
|
@ -459,7 +459,7 @@ async function ensureGroupData(groupId: number) {
|
|||
})
|
||||
const group = groupStore.getGroup(groupId)
|
||||
if (!group?.membersLoaded || group.membersExpired) {
|
||||
groupStore.fetchGroupMemberList(groupId, true).catch((error) => {
|
||||
groupStore.fetchGroupMemberList(groupId).catch((error) => {
|
||||
console.warn('[IM MessagePanel] fetchGroupMemberList 失败', { groupId }, error)
|
||||
})
|
||||
}
|
||||
|
|
@ -471,7 +471,7 @@ function reloadGroupData() {
|
|||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||
return
|
||||
}
|
||||
groupStore.fetchGroupInfo(conversation.targetId)
|
||||
groupStore.fetchGroupInfo(conversation.targetId, true)
|
||||
groupStore.fetchGroupMemberList(conversation.targetId, true)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,20 @@ const pendingDraftConversations = new Set<Conversation>()
|
|||
|
||||
type LegacyConversationDO = ConversationDO & { readMessageId?: number }
|
||||
|
||||
/** 创建会话读位置记录 */
|
||||
function createConversationRead(
|
||||
type: number,
|
||||
targetId: number,
|
||||
messageId: number
|
||||
): ConversationRead {
|
||||
return {
|
||||
conversationType: type,
|
||||
targetId,
|
||||
messageId,
|
||||
updateTime: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
/** 会话转 IndexedDB 记录 */
|
||||
function toConversationDO(conversation: Conversation): ConversationDO {
|
||||
const draft = conversation.draft
|
||||
|
|
@ -302,6 +316,15 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
return !!record && message.id <= record.messageId
|
||||
},
|
||||
|
||||
/** 判断会话读位置是否覆盖消息编号 */
|
||||
isReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
|
||||
if (!messageId) {
|
||||
return false
|
||||
}
|
||||
const record = this.getConversationRead(type, targetId)
|
||||
return !!record && record.messageId >= messageId
|
||||
},
|
||||
|
||||
/** 应用读位置到会话 */
|
||||
applyReadToConversation(conversation: Conversation, messageId: number): boolean {
|
||||
if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
|
||||
|
|
@ -652,7 +675,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
},
|
||||
|
||||
/** 标记会话已读 */
|
||||
markConversationRead(type: number, targetId: number, messageId?: number) {
|
||||
markConversationRead(type: number, targetId: number, messageId?: number): void {
|
||||
const conversation = this.getConversation(type, targetId)
|
||||
if (!conversation) {
|
||||
return
|
||||
|
|
@ -672,19 +695,25 @@ export const useConversationStore = defineStore('imConversationStore', {
|
|||
conversation.atMe = false
|
||||
conversation.atAll = false
|
||||
if (readMessageIdAdvanced) {
|
||||
const record = {
|
||||
conversationType: type,
|
||||
targetId,
|
||||
messageId,
|
||||
updateTime: Date.now()
|
||||
}
|
||||
const record = createConversationRead(type, targetId, messageId)
|
||||
this.conversationReads[key] = record
|
||||
void getDb()
|
||||
.transaction(['conversations', 'conversationReads'], 'readwrite', async (tx) => {
|
||||
await this.saveConversationRecord(conversation, tx)
|
||||
await this.saveConversationReadRecord(record, tx)
|
||||
})
|
||||
.catch((e) => console.warn('[IM conversationStore] 会话已读写入失败', e))
|
||||
.catch((e) =>
|
||||
console.warn(
|
||||
'[IM conversationStore] 会话已读写入失败',
|
||||
{
|
||||
conversationType: type,
|
||||
targetId,
|
||||
messageId,
|
||||
conversationKey: key
|
||||
},
|
||||
e
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
this.saveConversation(conversation)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n
|
|||
/** 构建群 IndexedDB 记录 */
|
||||
function buildGroupDO(group: Group): GroupDO {
|
||||
const {
|
||||
infoLoaded: _infoLoaded,
|
||||
members: _members,
|
||||
membersLoaded: _membersLoaded,
|
||||
membersExpired: _membersExpired,
|
||||
|
|
@ -232,10 +233,11 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
this.groups = fresh.map((group) => {
|
||||
const existing = groupMap.get(group.id)
|
||||
if (!existing) {
|
||||
return group
|
||||
return { ...group, infoLoaded: true }
|
||||
}
|
||||
return {
|
||||
...group,
|
||||
infoLoaded: true,
|
||||
members: existing.members,
|
||||
memberCount: existing.memberCount ?? group.memberCount,
|
||||
membersLoaded: existing.membersLoaded,
|
||||
|
|
@ -272,6 +274,13 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
}
|
||||
},
|
||||
|
||||
/** 失效全部群详情缓存 */
|
||||
markAllGroupInfoExpired() {
|
||||
for (const group of this.groups) {
|
||||
group.infoLoaded = false
|
||||
}
|
||||
},
|
||||
|
||||
/** 失效全部群成员缓存 */
|
||||
markAllGroupMembersExpired() {
|
||||
this.groupMembersExpired = true
|
||||
|
|
@ -291,13 +300,17 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
},
|
||||
|
||||
/** 单群刷新:用 /im/group/get 拉一份最新元数据再 upsert,常用于 GROUP_UPDATE 推送后或手动 reload */
|
||||
async fetchGroupInfo(groupId: number) {
|
||||
async fetchGroupInfo(groupId: number, force = false) {
|
||||
const cached = this.getGroup(groupId)
|
||||
if (cached?.infoLoaded && !force) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await apiGetGroup(groupId)
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
this.upsertGroup(convertGroup(data))
|
||||
this.upsertGroup({ ...convertGroup(data), infoLoaded: true })
|
||||
} catch (e) {
|
||||
console.warn('[IM groupStore] fetchGroupInfo 失败', e)
|
||||
}
|
||||
|
|
@ -709,7 +722,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
if (selfIsOperator && this.getGroup(groupId)) {
|
||||
return
|
||||
}
|
||||
await this.fetchGroupInfo(groupId)
|
||||
await this.fetchGroupInfo(groupId, true)
|
||||
},
|
||||
|
||||
/** 群名变更:按 newName 局部更新本地群名 */
|
||||
|
|
@ -724,12 +737,15 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' })
|
||||
},
|
||||
|
||||
/** 群信息变更(NAME / NOTICE 之外字段,当前承载头像变更) */
|
||||
/** 群信息变更:同步头像、进群审批 */
|
||||
applyGroupInfoUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
|
||||
const fields: Partial<Group> = {}
|
||||
if (payload.newAvatar) {
|
||||
fields.avatar = payload.newAvatar
|
||||
}
|
||||
if (payload.newJoinApproval != null) {
|
||||
fields.joinApproval = payload.newJoinApproval
|
||||
}
|
||||
if (Object.keys(fields).length > 0) {
|
||||
this.updateGroupFields(groupId, fields)
|
||||
}
|
||||
|
|
@ -739,7 +755,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
async applyGroupMemberInviteNotification(groupId: number, payload: GroupNotificationPayload) {
|
||||
// 自己刚被拉进来:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空
|
||||
if (isSelfInPayloadMembers(payload) && !this.getGroup(groupId)) {
|
||||
await this.fetchGroupInfo(groupId)
|
||||
await this.fetchGroupInfo(groupId, true)
|
||||
}
|
||||
this.markGroupMembersExpired(groupId)
|
||||
this.fetchGroupMemberList(groupId, true).catch(() => undefined)
|
||||
|
|
@ -750,7 +766,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
const selfUserId = getCurrentUserId()
|
||||
// 自己自由进群:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空
|
||||
if (selfUserId && payload.entrantUserId === selfUserId && !this.getGroup(groupId)) {
|
||||
await this.fetchGroupInfo(groupId)
|
||||
await this.fetchGroupInfo(groupId, true)
|
||||
}
|
||||
this.markGroupMembersExpired(groupId)
|
||||
this.fetchGroupMemberList(groupId, true).catch(() => undefined)
|
||||
|
|
|
|||
|
|
@ -241,6 +241,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
state: () => ({
|
||||
messagesByConversation: {} as Record<string, Message[]>,
|
||||
loadedConversationKeys: [] as string[],
|
||||
privateReadMaxIds: {} as Partial<Record<number, number>>,
|
||||
privateMessageMaxId: 0,
|
||||
groupMessageMaxId: 0,
|
||||
channelMessageMaxId: 0
|
||||
|
|
@ -265,6 +266,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
})
|
||||
this.messagesByConversation = {}
|
||||
this.loadedConversationKeys = []
|
||||
this.privateReadMaxIds = {}
|
||||
this.privateMessageMaxId = 0
|
||||
this.groupMessageMaxId = 0
|
||||
this.channelMessageMaxId = 0
|
||||
|
|
@ -304,6 +306,30 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
}
|
||||
},
|
||||
|
||||
/** 获取私聊对方已读位置缓存 */
|
||||
getPrivateReadMaxId(peerId: number): number | undefined {
|
||||
return this.privateReadMaxIds[peerId]
|
||||
},
|
||||
|
||||
/** 更新私聊对方已读位置缓存 */
|
||||
updatePrivateReadMaxId(peerId: number, maxReadId?: number | null): number {
|
||||
if (!peerId) {
|
||||
return 0
|
||||
}
|
||||
const nextMaxReadId = maxReadId || 0
|
||||
const current = this.getPrivateReadMaxId(peerId)
|
||||
if (current !== undefined && nextMaxReadId <= current) {
|
||||
return current
|
||||
}
|
||||
this.privateReadMaxIds = { ...this.privateReadMaxIds, [peerId]: nextMaxReadId }
|
||||
return nextMaxReadId
|
||||
},
|
||||
|
||||
/** 清空私聊对方已读位置缓存 */
|
||||
clearPrivateReadMaxIdCache(): void {
|
||||
this.privateReadMaxIds = {}
|
||||
},
|
||||
|
||||
/** 标记会话近期使用 */
|
||||
touchConversationMessageCache(clientConversationId: string) {
|
||||
this.loadedConversationKeys = [
|
||||
|
|
@ -790,6 +816,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
const changed: Message[] = []
|
||||
// 1. 私聊回执批量更新自己发送的消息
|
||||
if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
|
||||
this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId)
|
||||
messages.forEach((message) => {
|
||||
if (
|
||||
message.selfSend &&
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import { getCurrentUserId } from '@/utils/auth'
|
|||
import { useFriendStore } from './friendStore'
|
||||
import { useGroupStore } from './groupStore'
|
||||
|
||||
type GroupActiveCallCache = ImRtcGroupCallRespVO & {
|
||||
participantsLoaded?: boolean // 是否已拉取完整参与者列表
|
||||
}
|
||||
|
||||
// RTC_CALL 通话信令载荷;按 status 区分子类型语义
|
||||
export interface ImRtcCallNotification {
|
||||
status: ImRtcParticipantStatusValue
|
||||
|
|
@ -118,7 +122,7 @@ export const useRtcStore = defineStore('imRtc', () => {
|
|||
}
|
||||
|
||||
/** 群活跃通话索引;groupId -> 群通话摘要;用于群聊顶部胶囊条 */
|
||||
const groupActiveCalls = ref<Map<number, ImRtcGroupCallRespVO>>(new Map())
|
||||
const groupActiveCalls = ref<Map<number, GroupActiveCallCache>>(new Map())
|
||||
|
||||
/**
|
||||
* 已退出 / 已拒绝的用户编号集合;群通话场景内 pending 占位渲染时排除;
|
||||
|
|
@ -249,20 +253,51 @@ export const useRtcStore = defineStore('imRtc', () => {
|
|||
* 房内成员同步交给 LiveKit 客户端事件(ParticipantConnected / Disconnected);
|
||||
* 胶囊条不实时刷新 joinedUserIds / inviteeIds,展开 / 加入时再走 getActiveCall 接口拉最新
|
||||
*/
|
||||
function setGroupCall(payload: ImRtcGroupCallRespVO) {
|
||||
function setGroupCall(payload: ImRtcGroupCallRespVO, participantsLoaded?: boolean) {
|
||||
if (!payload?.groupId) {
|
||||
return
|
||||
}
|
||||
// 浅比较:room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算
|
||||
const existing = groupActiveCalls.value.get(payload.groupId)
|
||||
if (existing && isSameGroupCall(existing, payload)) {
|
||||
const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded
|
||||
if (
|
||||
existing &&
|
||||
isSameGroupCall(existing, payload) &&
|
||||
!!existing.participantsLoaded === nextParticipantsLoaded
|
||||
) {
|
||||
return
|
||||
}
|
||||
const newGroupActiveCalls = new Map(groupActiveCalls.value)
|
||||
newGroupActiveCalls.set(payload.groupId, payload)
|
||||
newGroupActiveCalls.set(payload.groupId, {
|
||||
...payload,
|
||||
participantsLoaded: nextParticipantsLoaded
|
||||
})
|
||||
groupActiveCalls.value = newGroupActiveCalls
|
||||
}
|
||||
|
||||
/** 清空指定群的通话缓存 */
|
||||
function clearGroupCallCache(groupId?: number) {
|
||||
if (!groupId) {
|
||||
groupActiveCalls.value = new Map()
|
||||
return
|
||||
}
|
||||
const next = new Map(groupActiveCalls.value)
|
||||
next.delete(groupId)
|
||||
groupActiveCalls.value = next
|
||||
}
|
||||
|
||||
/** 判断群通话是否已补齐 */
|
||||
function isGroupCallParticipantsLoaded(groupId: number, room?: string): boolean {
|
||||
const call = groupActiveCalls.value.get(groupId)
|
||||
return (
|
||||
!!groupId &&
|
||||
!!room &&
|
||||
!!call &&
|
||||
call.room === room &&
|
||||
!!call.participantsLoaded
|
||||
)
|
||||
}
|
||||
|
||||
/** 两条群通话摘要内容相等(room / mediaType / inviterId / 两个 userId 数组逐项相等) */
|
||||
function isSameGroupCall(a: ImRtcGroupCallRespVO, b: ImRtcGroupCallRespVO): boolean {
|
||||
if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) {
|
||||
|
|
@ -274,12 +309,10 @@ export const useRtcStore = defineStore('imRtc', () => {
|
|||
|
||||
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
|
||||
function removeGroupCall(groupId: number) {
|
||||
if (!groupId || !groupActiveCalls.value.has(groupId)) {
|
||||
if (!groupId) {
|
||||
return
|
||||
}
|
||||
const newGroupActiveCalls = new Map(groupActiveCalls.value)
|
||||
newGroupActiveCalls.delete(groupId)
|
||||
groupActiveCalls.value = newGroupActiveCalls
|
||||
clearGroupCallCache(groupId)
|
||||
}
|
||||
|
||||
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */
|
||||
|
|
@ -360,6 +393,10 @@ export const useRtcStore = defineStore('imRtc', () => {
|
|||
if (nextJoined.length === joined.length && nextInvitee.length === invitee.length) {
|
||||
return
|
||||
}
|
||||
if (nextJoined.length === 0 && nextInvitee.length === 0) {
|
||||
removeGroupCall(groupId)
|
||||
return
|
||||
}
|
||||
setGroupCall({
|
||||
...existing,
|
||||
joinedUserIds: nextJoined,
|
||||
|
|
@ -385,6 +422,8 @@ export const useRtcStore = defineStore('imRtc', () => {
|
|||
setGroupCall,
|
||||
removeGroupCall,
|
||||
getGroupCall,
|
||||
isGroupCallParticipantsLoaded,
|
||||
clearGroupCallCache,
|
||||
applyParticipantConnected,
|
||||
applyParticipantDisconnected,
|
||||
applyParticipantRejected,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from '../../utils/constants'
|
||||
import {
|
||||
getPrivateMessagePeerId,
|
||||
parseRtcCallPayload,
|
||||
playAudioTip,
|
||||
resolveCallEndReasonText
|
||||
} from '../../utils/message'
|
||||
|
|
@ -424,14 +425,31 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
)
|
||||
if (isActive) {
|
||||
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
|
||||
conversationStore.markConversationRead(
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
ImConversationType.CHANNEL,
|
||||
websocketMessage.channelId,
|
||||
websocketMessage.id
|
||||
)
|
||||
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id).catch((e) => {
|
||||
console.warn('[IM WS] 频道自动已读上报失败', e)
|
||||
})
|
||||
conversationStore.markConversationRead(
|
||||
ImConversationType.CHANNEL,
|
||||
websocketMessage.channelId,
|
||||
readCovered ? undefined : websocketMessage.id
|
||||
)
|
||||
if (!readCovered) {
|
||||
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
|
||||
.catch((e) => {
|
||||
console.warn(
|
||||
'[IM WS] 频道自动已读上报失败',
|
||||
{
|
||||
conversationType: ImConversationType.CHANNEL,
|
||||
channelId: websocketMessage.channelId,
|
||||
messageId: websocketMessage.id,
|
||||
messageType: websocketMessage.type
|
||||
},
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
|
||||
// 非当前会话且未免打扰:响一下提示音
|
||||
playAudioTip()
|
||||
|
|
@ -515,7 +533,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
this.handleGroupMemberNicknameUpdate(websocketMessage)
|
||||
break
|
||||
case ImContentType.RTC_CALL_START:
|
||||
// 入库 + 渲染聊天 tip;胶囊条状态走 1602/1603,本帧不动 rtcStore,避免与首次填充竞争
|
||||
// 入库 + 渲染聊天 tip;同时用 START payload 先生成最小胶囊条,后续 getActiveCall / 参与者事件再补齐成员
|
||||
this.handleRtcCallStart(websocketMessage)
|
||||
ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
|
||||
break
|
||||
case ImContentType.RTC_CALL_END:
|
||||
|
|
@ -611,15 +630,31 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
if (isActive) {
|
||||
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
|
||||
// 已读位置直接用刚到的消息 id(这条就是当前会话最大 id)
|
||||
conversationStore.markConversationRead(
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
ImConversationType.PRIVATE,
|
||||
peerId,
|
||||
websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_PRIVATE_READ_ENABLED) {
|
||||
apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => {
|
||||
console.warn('[IM WS] 自动已读上报失败', e)
|
||||
})
|
||||
conversationStore.markConversationRead(
|
||||
ImConversationType.PRIVATE,
|
||||
peerId,
|
||||
readCovered ? undefined : websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) {
|
||||
apiReadPrivateMessages(peerId, websocketMessage.id)
|
||||
.catch((e) => {
|
||||
console.warn(
|
||||
'[IM WS] 私聊自动已读上报失败',
|
||||
{
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
peerId,
|
||||
messageId: websocketMessage.id,
|
||||
messageType: websocketMessage.type,
|
||||
senderId: websocketMessage.senderId
|
||||
},
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
|
||||
// 非当前会话且未免打扰:响一下提示音(带节流,详见 playAudioTip);FRIEND_* 等系统事件不响
|
||||
|
|
@ -713,7 +748,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
// 2. 未知群时自动拉群详情 + 成员(被拉入群但还没收到 GROUP_CREATE 时的兜底)
|
||||
const group = groupStore.getGroup(websocketMessage.groupId)
|
||||
if (!group) {
|
||||
groupStore.fetchGroupInfo(websocketMessage.groupId).catch(() => undefined)
|
||||
groupStore.fetchGroupInfo(websocketMessage.groupId, true).catch(() => undefined)
|
||||
}
|
||||
|
||||
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`
|
||||
|
|
@ -750,15 +785,31 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
conversationStore.activeConversation?.targetId === websocketMessage.groupId
|
||||
if (isActive) {
|
||||
// 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零
|
||||
conversationStore.markConversationRead(
|
||||
const readCovered = conversationStore.isReadPositionCovered(
|
||||
ImConversationType.GROUP,
|
||||
websocketMessage.groupId,
|
||||
websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_GROUP_READ_ENABLED) {
|
||||
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => {
|
||||
console.warn('[IM WS] 自动已读上报失败', e)
|
||||
})
|
||||
conversationStore.markConversationRead(
|
||||
ImConversationType.GROUP,
|
||||
websocketMessage.groupId,
|
||||
readCovered ? undefined : websocketMessage.id
|
||||
)
|
||||
if (MESSAGE_GROUP_READ_ENABLED && !readCovered) {
|
||||
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
|
||||
.catch((e) => {
|
||||
console.warn(
|
||||
'[IM WS] 群聊自动已读上报失败',
|
||||
{
|
||||
conversationType: ImConversationType.GROUP,
|
||||
groupId: websocketMessage.groupId,
|
||||
messageId: websocketMessage.id,
|
||||
messageType: websocketMessage.type,
|
||||
senderId: websocketMessage.senderId
|
||||
},
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
} else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
|
||||
// GROUP_* 群广播事件等系统消息不响提示音
|
||||
|
|
@ -1063,6 +1114,27 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
}
|
||||
},
|
||||
|
||||
/** RTC_CALL_START 通话开始 */
|
||||
handleRtcCallStart(websocketMessage: ImGroupMessageNotification) {
|
||||
const payload = parseRtcCallPayload(websocketMessage.content)
|
||||
if (!payload?.room || !payload.mediaType || !payload.inviterUserId) {
|
||||
console.warn('[IM WS] RTC_CALL_START payload 不合法', {
|
||||
groupId: websocketMessage.groupId,
|
||||
messageId: websocketMessage.id,
|
||||
contentLength: websocketMessage.content?.length ?? 0
|
||||
})
|
||||
return
|
||||
}
|
||||
useRtcStore().setGroupCall({
|
||||
room: payload.room,
|
||||
groupId: websocketMessage.groupId,
|
||||
mediaType: payload.mediaType,
|
||||
inviterId: payload.inviterUserId,
|
||||
joinedUserIds: [payload.inviterUserId],
|
||||
inviteeIds: []
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* RTC_CALL_END 通话结束;私聊 + 群聊都走这一条;payload 携带 conversationType 区分
|
||||
* <p>
|
||||
|
|
|
|||
|
|
@ -194,12 +194,13 @@ export interface Group {
|
|||
silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填
|
||||
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
|
||||
members?: GroupMember[] // 群成员缓存(按需懒加载)
|
||||
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
|
||||
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
|
||||
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
|
||||
memberCount?: number // 成员总数
|
||||
}
|
||||
|
||||
export type GroupDO = Omit<Group, 'members' | 'membersLoaded' | 'membersExpired'>
|
||||
export type GroupDO = Omit<Group, 'infoLoaded' | 'members' | 'membersLoaded' | 'membersExpired'>
|
||||
|
||||
// 群成员实体(前端内部结构)
|
||||
export interface GroupMember {
|
||||
|
|
|
|||
|
|
@ -1,25 +1,30 @@
|
|||
<template>
|
||||
<el-row :gutter="16">
|
||||
<el-col v-for="card in cards" :key="card.title" :xl="6" :lg="6" :md="12" :sm="24" :xs="24">
|
||||
<el-card shadow="never" class="!rounded-8px mb-16px">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="w-48px h-48px rounded-8px flex items-center justify-center mr-12px flex-shrink-0"
|
||||
:style="{ backgroundColor: card.color }"
|
||||
>
|
||||
<Icon :icon="card.icon" :size="24" color="#fff" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-13px text-[var(--el-text-color-secondary)] mb-4px">{{ card.title }}</div>
|
||||
<div class="text-22px font-600 text-[var(--el-text-color-primary)] leading-none">
|
||||
<CountTo :start-val="0" :end-val="card.value" :duration="1500" />
|
||||
<span v-if="card.suffix" class="text-12px text-[var(--el-text-color-placeholder)] ml-6px font-normal">{{ card.suffix }}</span>
|
||||
<el-card shadow="never" class="kpi-card !rounded-8px mb-16px">
|
||||
<el-skeleton :loading="loading" :rows="2" animated>
|
||||
<div class="flex items-center">
|
||||
<div class="kpi-card__icon mr-14px" :style="{ background: card.gradient }">
|
||||
<Icon :icon="card.icon" :size="24" color="#fff" />
|
||||
</div>
|
||||
<div class="text-12px text-[var(--el-text-color-placeholder)] mt-6px">{{ card.metaLabel }}:
|
||||
<span :class="card.metaClass">{{ card.metaValue }}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-13px text-[var(--el-text-color-secondary)]">{{ card.title }}</div>
|
||||
<div class="mt-6px text-24px font-600 leading-none text-[var(--el-text-color-primary)]">
|
||||
<CountTo :start-val="0" :end-val="card.value" :duration="1500" />
|
||||
<span
|
||||
v-if="card.suffix"
|
||||
class="ml-6px text-12px font-normal text-[var(--el-text-color-placeholder)]"
|
||||
>
|
||||
{{ card.suffix }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-8px flex items-center text-12px text-[var(--el-text-color-placeholder)]">
|
||||
<span>{{ card.metaLabel }}</span>
|
||||
<span class="ml-6px font-500" :class="card.metaClass">{{ card.metaValue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-skeleton>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
|
@ -30,7 +35,13 @@ import type { ImStatisticsOverviewVO } from '@/api/im/manager/statistics'
|
|||
|
||||
defineOptions({ name: 'ImStatisticsOverviewCards' })
|
||||
|
||||
const props = defineProps<{ overview: ImStatisticsOverviewVO }>()
|
||||
const props = withDefaults(
|
||||
defineProps<{ overview?: ImStatisticsOverviewVO; loading?: boolean }>(),
|
||||
{ loading: false }
|
||||
)
|
||||
|
||||
// 概览数据兜底,避免 loading 阶段 overview 未就绪时取值报错
|
||||
const o = computed(() => props.overview ?? ({} as ImStatisticsOverviewVO))
|
||||
|
||||
const calcRatio = (today: number, yesterday: number): { label: string; cls: string } => {
|
||||
if (!yesterday) return { label: '无昨日数据', cls: 'text-gray-400' }
|
||||
|
|
@ -43,44 +54,44 @@ const calcRatio = (today: number, yesterday: number): { label: string; cls: stri
|
|||
}
|
||||
|
||||
const cards = computed(() => {
|
||||
const o = props.overview
|
||||
const totalMsgToday = (o.privateMessageToday ?? 0) + (o.groupMessageToday ?? 0)
|
||||
const totalMsgYesterday = (o.privateMessageYesterday ?? 0) + (o.groupMessageYesterday ?? 0)
|
||||
const v = o.value
|
||||
const totalMsgToday = (v.privateMessageToday ?? 0) + (v.groupMessageToday ?? 0)
|
||||
const totalMsgYesterday = (v.privateMessageYesterday ?? 0) + (v.groupMessageYesterday ?? 0)
|
||||
const msgRatio = calcRatio(totalMsgToday, totalMsgYesterday)
|
||||
return [
|
||||
{
|
||||
title: '总用户',
|
||||
value: o.totalUser ?? 0,
|
||||
value: v.totalUser ?? 0,
|
||||
icon: 'ep:user',
|
||||
color: '#409EFF',
|
||||
gradient: 'linear-gradient(135deg, #5b9cff 0%, #409eff 100%)',
|
||||
metaLabel: '今日新增',
|
||||
metaValue: `+${o.newUserToday ?? 0}`,
|
||||
metaValue: `+${v.newUserToday ?? 0}`,
|
||||
metaClass: 'text-green-500'
|
||||
},
|
||||
{
|
||||
title: '总群组',
|
||||
value: o.totalGroup ?? 0,
|
||||
value: v.totalGroup ?? 0,
|
||||
icon: 'ep:chat-dot-round',
|
||||
color: '#67C23A',
|
||||
gradient: 'linear-gradient(135deg, #5bd6a0 0%, #67c23a 100%)',
|
||||
metaLabel: '今日新增',
|
||||
metaValue: `+${o.newGroupToday ?? 0}`,
|
||||
metaValue: `+${v.newGroupToday ?? 0}`,
|
||||
metaClass: 'text-green-500'
|
||||
},
|
||||
{
|
||||
title: '日活用户',
|
||||
value: o.activeUserDaily ?? 0,
|
||||
value: v.activeUserDaily ?? 0,
|
||||
icon: 'ep:timer',
|
||||
color: '#E6A23C',
|
||||
metaLabel: '周/月活',
|
||||
metaValue: `${o.activeUserWeekly ?? 0} / ${o.activeUserMonthly ?? 0}`,
|
||||
gradient: 'linear-gradient(135deg, #ffc46b 0%, #e6a23c 100%)',
|
||||
metaLabel: '周 / 月活',
|
||||
metaValue: `${v.activeUserWeekly ?? 0} / ${v.activeUserMonthly ?? 0}`,
|
||||
metaClass: 'text-gray-500'
|
||||
},
|
||||
{
|
||||
title: '今日消息',
|
||||
value: totalMsgToday,
|
||||
suffix: ` (P ${o.privateMessageToday}/G ${o.groupMessageToday})`,
|
||||
suffix: `(私 ${v.privateMessageToday ?? 0} / 群 ${v.groupMessageToday ?? 0})`,
|
||||
icon: 'ep:message',
|
||||
color: '#909399',
|
||||
gradient: 'linear-gradient(135deg, #b794f6 0%, #805ad5 100%)',
|
||||
metaLabel: '环比昨日',
|
||||
metaValue: msgRatio.label,
|
||||
metaClass: msgRatio.cls
|
||||
|
|
@ -88,3 +99,26 @@ const cards = computed(() => {
|
|||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.kpi-card {
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
<template>
|
||||
<div class="p-16px">
|
||||
<!-- 概览卡片 -->
|
||||
<OverviewCards v-if="overview" :overview="overview" />
|
||||
<div class="im-statistics">
|
||||
<!-- 页头 -->
|
||||
<div class="im-statistics__hero">
|
||||
<div class="im-statistics__hero-title">IM 数据看板</div>
|
||||
<div class="im-statistics__hero-desc">用户、群组与消息的整体运营概览</div>
|
||||
</div>
|
||||
|
||||
<!-- 趋势 -->
|
||||
<!-- 概览卡片 -->
|
||||
<OverviewCards :overview="overview" :loading="overviewLoading" />
|
||||
|
||||
<!-- 趋势:消息 + 用户 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :xl="12" :lg="12" :md="24" :sm="24" :xs="24">
|
||||
<MessageTrendChart />
|
||||
|
|
@ -13,7 +19,7 @@
|
|||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 分布 -->
|
||||
<!-- 分布:内容类型 + 群规模 + TOP 发送者 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :xl="8" :lg="8" :md="24" :sm="24" :xs="24">
|
||||
<MessageTypeChart />
|
||||
|
|
@ -41,8 +47,38 @@ defineOptions({ name: 'ImStatistics' })
|
|||
|
||||
// 父页只拉概览数据;趋势 / 分布组件各自独立拉取,互不阻塞
|
||||
const overview = ref<StatisticsApi.ImStatisticsOverviewVO>()
|
||||
const overviewLoading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
overview.value = await StatisticsApi.getStatisticsOverview()
|
||||
overviewLoading.value = true
|
||||
try {
|
||||
overview.value = await StatisticsApi.getStatisticsOverview()
|
||||
} finally {
|
||||
overviewLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.im-statistics {
|
||||
padding: 16px;
|
||||
|
||||
&__hero {
|
||||
margin-bottom: 16px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
&__hero-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
&__hero-desc {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -677,6 +677,8 @@ export type GroupNotificationPayload = {
|
|||
newNotice?: string
|
||||
oldAvatar?: string
|
||||
newAvatar?: string
|
||||
oldJoinApproval?: boolean
|
||||
newJoinApproval?: boolean
|
||||
displayUserName?: string
|
||||
messageId?: number
|
||||
// 禁言事件
|
||||
|
|
|
|||
Loading…
Reference in New Issue