实现IM聊天群聊功能,包括群聊列表和创建群聊接口,优化消息处理和未读消息统计,增强用户体验。
This commit is contained in:
@@ -10,6 +10,15 @@ import { useUserStore } from '/@/store/modules/user';
|
||||
let result: WebSocketResult<any>;
|
||||
const listeners = new Map();
|
||||
let connectedUrl = '';
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】WS 重连后通知监听方重新拉取消息-----------
|
||||
let wsConnectionCount = 0;
|
||||
const reconnectListeners = new Set<() => void>();
|
||||
|
||||
export function onWebSocketReconnect(callback: () => void) {
|
||||
reconnectListeners.add(callback);
|
||||
return () => reconnectListeners.delete(callback);
|
||||
}
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】WS 重连后通知监听方重新拉取消息-----------
|
||||
|
||||
/**
|
||||
* 构建系统 WebSocket 地址(含 context-path,如 /jeecg-boot)
|
||||
@@ -67,7 +76,15 @@ export function connectWebSocket(url: string) {
|
||||
protocols: [token],
|
||||
// 代码逻辑说明: [issues/6662] 演示系统socket总断,换一个写法
|
||||
onConnected: function (ws) {
|
||||
wsConnectionCount++;
|
||||
console.log('[WebSocket] 连接成功', ws);
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】WS 重连后通知监听方重新拉取消息-----------
|
||||
if (wsConnectionCount > 1) {
|
||||
reconnectListeners.forEach((cb) => {
|
||||
try { cb(); } catch (err) { console.error(err); }
|
||||
});
|
||||
}
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】WS 重连后通知监听方重新拉取消息-----------
|
||||
},
|
||||
onDisconnected: function (ws, event) {
|
||||
console.log('[WebSocket] 连接断开:', ws, event);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@
|
||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
import { BasicModal, useModalInner } from '/@/components/Modal';
|
||||
import ImChat from './ImChat.vue';
|
||||
import { setImChatWindowOpen } from './imCache';
|
||||
import { setImChatWindowOpen, setImActiveTargetUserId } from './imCache';
|
||||
import { canOpenImChatModal, onImChatModalCloseRequest, openImChat, setImPageContext } from './imSession';
|
||||
import { refreshImUnread } from './useImUnread';
|
||||
|
||||
@@ -28,29 +28,53 @@
|
||||
|
||||
const imChatRef = ref<InstanceType<typeof ImChat>>();
|
||||
const pendingTargetUserId = ref('');
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】支持从消息提醒直接打开群聊-----------
|
||||
const pendingConversationId = ref('');
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】支持从消息提醒直接打开群聊-----------
|
||||
|
||||
function restoreChatSession() {
|
||||
nextTick(async () => {
|
||||
const targetUserId = pendingTargetUserId.value;
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】支持从消息提醒直接打开群聊-----------
|
||||
const conversationId = pendingConversationId.value;
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】支持从消息提醒直接打开群聊-----------
|
||||
if (targetUserId) {
|
||||
pendingTargetUserId.value = '';
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】预设本地活跃会话,防止 WS 消息在异步初始化期间丢失-----------
|
||||
imChatRef.value?.presetActivePeer?.(targetUserId);
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】预设本地活跃会话,防止 WS 消息在异步初始化期间丢失-----------
|
||||
await imChatRef.value?.openTargetChat?.(targetUserId);
|
||||
return;
|
||||
}
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】支持从消息提醒直接打开群聊-----------
|
||||
if (conversationId) {
|
||||
pendingConversationId.value = '';
|
||||
await imChatRef.value?.openGroupConversation?.(conversationId);
|
||||
return;
|
||||
}
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】支持从消息提醒直接打开群聊-----------
|
||||
imChatRef.value?.restoreSessionIfNeeded?.();
|
||||
});
|
||||
}
|
||||
|
||||
const [registerModal, { closeModal }] = useModalInner((data?: { targetUserId?: string }) => {
|
||||
const [registerModal, { closeModal }] = useModalInner((data?: { targetUserId?: string; conversationId?: string }) => {
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天-OA】全页 IM 已打开时拒绝弹窗,改由全页承接-----------
|
||||
if (!canOpenImChatModal()) {
|
||||
closeModal();
|
||||
openImChat({ targetUserId: data?.targetUserId });
|
||||
openImChat({ targetUserId: data?.targetUserId, conversationId: data?.conversationId });
|
||||
return;
|
||||
}
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天-OA】全页 IM 已打开时拒绝弹窗,改由全页承接-----------
|
||||
setImChatWindowOpen(true);
|
||||
pendingTargetUserId.value = data?.targetUserId || '';
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】支持从消息提醒直接打开群聊-----------
|
||||
pendingConversationId.value = data?.conversationId || '';
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】支持从消息提醒直接打开群聊-----------
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】弹窗打开立即预热全局活跃 userId,避免 WS 消息在 nextTick 前丢失-----------
|
||||
if (data?.targetUserId) {
|
||||
setImActiveTargetUserId(data.targetUserId);
|
||||
}
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】弹窗打开立即预热全局活跃 userId,避免 WS 消息在 nextTick 前丢失-----------
|
||||
restoreChatSession();
|
||||
});
|
||||
|
||||
|
||||
161
jeecgboot-vue3/src/views/system/im/ImCreateGroupModal.vue
Normal file
161
jeecgboot-vue3/src/views/system/im/ImCreateGroupModal.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<BasicModal v-bind="$attrs" title="发起群聊" :width="520" @register="registerModal" @ok="handleSubmit">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="群名称" required>
|
||||
<a-input v-model:value="groupName" placeholder="请输入群名称" :maxlength="30" show-count />
|
||||
</a-form-item>
|
||||
<a-form-item label="选择成员" required>
|
||||
<a-input
|
||||
v-model:value="memberKeyword"
|
||||
placeholder="搜索同事"
|
||||
allow-clear
|
||||
size="small"
|
||||
class="member-search"
|
||||
@pressEnter="loadMembers"
|
||||
/>
|
||||
<a-spin :spinning="loading">
|
||||
<div class="member-list">
|
||||
<a-checkbox-group v-model:value="checkedMemberIds" class="member-checkbox-group">
|
||||
<div v-for="item in filteredMembers" :key="item.id" class="member-row">
|
||||
<a-checkbox :value="item.id">
|
||||
<div class="member-info">
|
||||
<a-avatar :size="28" :src="getAvatarUrl(item.avatar)">
|
||||
{{ (item.realname || item.username || '?').slice(0, 1) }}
|
||||
</a-avatar>
|
||||
<span class="member-name">{{ item.realname || item.username }}</span>
|
||||
</div>
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</a-checkbox-group>
|
||||
<a-empty v-if="!filteredMembers.length" description="暂无可选同事" />
|
||||
</div>
|
||||
</a-spin>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { BasicModal, useModalInner } from '/@/components/Modal';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
|
||||
import { useUserStore } from '/@/store/modules/user';
|
||||
import { createGroupConversation, fetchDeptMembers } from './im.api';
|
||||
import { getCachedMembers } from './imCache';
|
||||
|
||||
defineOptions({ name: 'ImCreateGroupModal' });
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [group: Recordable];
|
||||
}>();
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
const groupName = ref('');
|
||||
const memberKeyword = ref('');
|
||||
const checkedMemberIds = ref<string[]>([]);
|
||||
const members = ref<Recordable[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const filteredMembers = computed(() => {
|
||||
const keyword = memberKeyword.value.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return members.value;
|
||||
}
|
||||
return members.value.filter((item) => {
|
||||
const name = `${item.realname || ''}${item.username || ''}`.toLowerCase();
|
||||
return name.includes(keyword);
|
||||
});
|
||||
});
|
||||
|
||||
function resolveSelectableMembers(source?: Recordable[]) {
|
||||
const currentUserId = userStore.getUserInfo?.id || '';
|
||||
const list = source?.length ? source : getCachedMembers() || [];
|
||||
return list.filter((item) => item.id && item.id !== currentUserId);
|
||||
}
|
||||
|
||||
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data?: { members?: Recordable[] }) => {
|
||||
groupName.value = '';
|
||||
memberKeyword.value = '';
|
||||
checkedMemberIds.value = [];
|
||||
setModalProps({ confirmLoading: false });
|
||||
members.value = resolveSelectableMembers(data?.members);
|
||||
await loadMembers();
|
||||
});
|
||||
|
||||
function getAvatarUrl(avatar?: string) {
|
||||
return avatar ? getFileAccessHttpUrl(avatar) : '';
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = ((await fetchDeptMembers(memberKeyword.value || undefined)) || []) as Recordable[];
|
||||
members.value = resolveSelectableMembers(list);
|
||||
} catch {
|
||||
createMessage.error('加载同事列表失败,请稍后重试');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const name = groupName.value.trim();
|
||||
if (!name) {
|
||||
createMessage.warning('请输入群名称');
|
||||
return;
|
||||
}
|
||||
if (!checkedMemberIds.value.length) {
|
||||
createMessage.warning('请至少选择1名同事');
|
||||
return;
|
||||
}
|
||||
setModalProps({ confirmLoading: true });
|
||||
try {
|
||||
const group = await createGroupConversation({
|
||||
groupName: name,
|
||||
memberUserIds: checkedMemberIds.value,
|
||||
});
|
||||
emit('success', group);
|
||||
closeModal();
|
||||
} finally {
|
||||
setModalProps({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.member-search {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.member-checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-row {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,19 @@ enum Api {
|
||||
messages = '/sys/im/chat/messages',
|
||||
send = '/sys/im/chat/send',
|
||||
read = '/sys/im/chat/read',
|
||||
groups = '/sys/im/chat/groups',
|
||||
groupCreate = '/sys/im/chat/group/create',
|
||||
}
|
||||
|
||||
export interface ImGroupConversation {
|
||||
conversationId: string;
|
||||
convType?: string;
|
||||
groupName?: string;
|
||||
memberCount?: number;
|
||||
ownerId?: string;
|
||||
lastContent?: string;
|
||||
lastTime?: string;
|
||||
unreadCount?: number;
|
||||
}
|
||||
|
||||
export const fetchDeptMembers = (keyword?: string) => defHttp.get({ url: Api.deptMembers, params: { keyword } });
|
||||
@@ -39,3 +52,8 @@ export const sendMessage = (data: { conversationId: string; content: string; msg
|
||||
|
||||
export const markRead = (conversationId: string) =>
|
||||
defHttp.put({ url: Api.read, params: { conversationId } }, { joinParamsToUrl: true, successMessageMode: 'none' });
|
||||
|
||||
export const fetchGroups = () => defHttp.get<ImGroupConversation[]>({ url: Api.groups });
|
||||
|
||||
export const createGroupConversation = (data: { groupName: string; memberUserIds: string[] }) =>
|
||||
defHttp.post<ImGroupConversation>({ url: Api.groupCreate, data });
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { ref as vueRef } from 'vue';
|
||||
import { createSessionStorage } from '/@/utils/cache';
|
||||
import { useUserStoreWithOut } from '/@/store/modules/user';
|
||||
import { fetchDeptMembers, fetchMessages } from './im.api';
|
||||
import { fetchDeptMembers, fetchMessages, fetchGroups } from './im.api';
|
||||
import { getImDefaultStartTime } from './imSettings';
|
||||
import { formatImMessagePreview } from './imMessageUtil';
|
||||
import { syncImUnreadFromMembers } from './useImUnread';
|
||||
@@ -67,6 +68,25 @@ export function onImMessagesUpdated(listener: ImMessagesUpdatedListener) {
|
||||
return () => messageUpdatedListeners.delete(listener);
|
||||
}
|
||||
|
||||
type ImChatSocketUiListener = (data: Record<string, any>) => void;
|
||||
const chatSocketUiListeners = new Set<ImChatSocketUiListener>();
|
||||
|
||||
/** IM 聊天 UI 层 WS 分发(避免多 ImChat 实例重复绑定 onWebSocket) */
|
||||
export function onImChatSocketUi(listener: ImChatSocketUiListener) {
|
||||
chatSocketUiListeners.add(listener);
|
||||
return () => chatSocketUiListeners.delete(listener);
|
||||
}
|
||||
|
||||
function dispatchImChatSocketUi(data: Record<string, any>) {
|
||||
chatSocketUiListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type ImChatWindowOpenListener = (open: boolean) => void;
|
||||
const chatWindowOpenListeners = new Set<ImChatWindowOpenListener>();
|
||||
|
||||
@@ -187,11 +207,83 @@ export function clearImCache() {
|
||||
activeConversationId = '';
|
||||
activeTargetUserId = '';
|
||||
imChatWindowOpen = false;
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】清理群聊未读缓存-----------
|
||||
groupUnreadCache.clear();
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】清理群聊未读缓存-----------
|
||||
if (scope) {
|
||||
sessionCache.remove(`${CACHE_KEY}:${scope}`);
|
||||
}
|
||||
}
|
||||
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】群聊未读缓存,独立于成员缓存,支持 WS 实时累加和角标统计-----------
|
||||
interface ImGroupUnreadEntry {
|
||||
unreadCount: number;
|
||||
lastContent?: string;
|
||||
lastTime?: string;
|
||||
}
|
||||
|
||||
/** key = conversationId */
|
||||
const groupUnreadCache = new Map<string, ImGroupUnreadEntry>();
|
||||
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】响应式群聊未读总数,供 ImChat.vue tab 角标实时更新-----------
|
||||
/** 响应式群聊未读总数(跨组件共享,无需加载群列表即可显示 tab 角标) */
|
||||
export const reactiveGroupUnreadCount = vueRef(0);
|
||||
|
||||
function recalcGroupUnreadCount() {
|
||||
let total = 0;
|
||||
for (const entry of groupUnreadCache.values()) {
|
||||
total += entry.unreadCount;
|
||||
}
|
||||
reactiveGroupUnreadCount.value = total;
|
||||
}
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】响应式群聊未读总数,供 ImChat.vue tab 角标实时更新-----------
|
||||
|
||||
/** 从接口列表初始化群聊未读缓存(登录预取或 loadGroups 后调用) */
|
||||
export function initGroupUnreadFromList(
|
||||
groups: Array<{ conversationId: string; unreadCount?: number; lastContent?: string; lastTime?: string }>,
|
||||
) {
|
||||
for (const group of groups || []) {
|
||||
if (group.conversationId) {
|
||||
groupUnreadCache.set(group.conversationId, {
|
||||
unreadCount: group.unreadCount || 0,
|
||||
lastContent: group.lastContent,
|
||||
lastTime: group.lastTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
recalcGroupUnreadCount();
|
||||
}
|
||||
|
||||
/** WS 收到群消息时累加未读 */
|
||||
export function incrementGroupUnread(conversationId: string, lastContent: string, lastTime: string) {
|
||||
const current = groupUnreadCache.get(conversationId) || { unreadCount: 0 };
|
||||
groupUnreadCache.set(conversationId, {
|
||||
unreadCount: current.unreadCount + 1,
|
||||
lastContent,
|
||||
lastTime,
|
||||
});
|
||||
recalcGroupUnreadCount();
|
||||
}
|
||||
|
||||
/** 打开群聊会话时清零(已读) */
|
||||
export function resetGroupUnread(conversationId: string) {
|
||||
const current = groupUnreadCache.get(conversationId);
|
||||
if (current) {
|
||||
groupUnreadCache.set(conversationId, { ...current, unreadCount: 0 });
|
||||
recalcGroupUnreadCount();
|
||||
}
|
||||
}
|
||||
|
||||
/** 供 syncImUnreadFromMembers 和 refreshImUnread 合并到总未读统计 */
|
||||
export function getCachedGroupUnreadItems(): Array<{ id: string; conversationId: string; unreadCount: number }> {
|
||||
return Array.from(groupUnreadCache.entries()).map(([id, data]) => ({
|
||||
id,
|
||||
conversationId: id,
|
||||
unreadCount: data.unreadCount,
|
||||
}));
|
||||
}
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】群聊未读缓存,独立于成员缓存,支持 WS 实时累加和角标统计-----------
|
||||
|
||||
export function getCachedMembers(): ImMemberItem[] | null {
|
||||
const snap = ensureMemory();
|
||||
return snap.members.length ? snap.members : null;
|
||||
@@ -323,6 +415,15 @@ export async function prefetchImChatData(force = false): Promise<void> {
|
||||
setCachedMembers(members);
|
||||
syncImUnreadFromMembers(members);
|
||||
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】预取群聊未读数,使顶部角标包含群聊未读-----------
|
||||
fetchGroups()
|
||||
.then((groups: any[]) => {
|
||||
initGroupUnreadFromList(groups || []);
|
||||
syncImUnreadFromMembers([...members, ...getCachedGroupUnreadItems()]);
|
||||
})
|
||||
.catch(() => {});
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】预取群聊未读数,使顶部角标包含群聊未读-----------
|
||||
|
||||
const candidates = members
|
||||
.filter((item) => item.conversationId && (item.unreadCount || item.lastTime))
|
||||
.sort((a, b) => dayjs(b.lastTime || 0).valueOf() - dayjs(a.lastTime || 0).valueOf())
|
||||
@@ -342,14 +443,37 @@ export async function prefetchImChatData(force = false): Promise<void> {
|
||||
}
|
||||
|
||||
/** 顶部角标:收到新消息时更新缓存未读数 */
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】按 messageId 去重,防止多个 WS 监听者重复调用导致角标翻倍-----------
|
||||
const _processedMsgIds = new Set<string>();
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】按 messageId 去重,防止多个 WS 监听者重复调用导致角标翻倍-----------
|
||||
export function handleImChatSocket(data: Record<string, any>) {
|
||||
if (data.cmd !== 'chat') {
|
||||
return;
|
||||
}
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】同一消息去重处理,避免多监听器重复累加未读-----------
|
||||
const msgId = data.messageId as string;
|
||||
if (msgId) {
|
||||
if (_processedMsgIds.has(msgId)) {
|
||||
return;
|
||||
}
|
||||
_processedMsgIds.add(msgId);
|
||||
// 5 秒后清理,防止 Set 无限增长
|
||||
setTimeout(() => _processedMsgIds.delete(msgId), 5000);
|
||||
}
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】同一消息去重处理,避免多监听器重复累加未读-----------
|
||||
const conversationId = data.conversationId as string;
|
||||
const senderId = data.senderId as string;
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天-OA】单聊 WS 即时同步缓存并区分群聊-----------
|
||||
const isGroupMessage = data.convType === 'group';
|
||||
const isViewingSinglePeer = !!activeTargetUserId && activeTargetUserId === senderId;
|
||||
const isActiveConversation =
|
||||
isImChatUiOpen() && !!conversationId && conversationId === activeConversationId;
|
||||
isImChatUiOpen() &&
|
||||
!!conversationId &&
|
||||
(conversationId === activeConversationId || (!isGroupMessage && isViewingSinglePeer));
|
||||
|
||||
if (isActiveConversation && conversationId !== activeConversationId) {
|
||||
activeConversationId = conversationId;
|
||||
}
|
||||
|
||||
if (isActiveConversation) {
|
||||
const userStore = useUserStoreWithOut();
|
||||
@@ -365,21 +489,32 @@ export function handleImChatSocket(data: Record<string, any>) {
|
||||
mine: senderId === currentUserId,
|
||||
createTime: data.createTime,
|
||||
});
|
||||
const members = getCachedMembers() || [];
|
||||
const peerMember = members.find((item) => item.conversationId === conversationId);
|
||||
const peerUserId = peerMember?.id || (senderId !== currentUserId ? senderId : activeTargetUserId);
|
||||
if (peerUserId) {
|
||||
patchCachedMember(
|
||||
peerUserId,
|
||||
{
|
||||
conversationId,
|
||||
lastContent: formatImMessagePreview(data.content, data.msgType),
|
||||
lastTime: data.createTime,
|
||||
unreadCount: 0,
|
||||
},
|
||||
{ moveToTop: true },
|
||||
);
|
||||
if (isGroupMessage) {
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】活跃群聊消息到达时,合并群聊未读数到角标-----------
|
||||
syncImUnreadFromMembers([...(getCachedMembers() || []), ...getCachedGroupUnreadItems()]);
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】活跃群聊消息到达时,合并群聊未读数到角标-----------
|
||||
} else {
|
||||
const members = getCachedMembers() || [];
|
||||
const peerMember = members.find((item) => item.conversationId === conversationId);
|
||||
const peerUserId = peerMember?.id || (senderId !== currentUserId ? senderId : activeTargetUserId);
|
||||
if (peerUserId) {
|
||||
patchCachedMember(
|
||||
peerUserId,
|
||||
{
|
||||
conversationId,
|
||||
lastContent: formatImMessagePreview(data.content, data.msgType),
|
||||
lastTime: data.createTime,
|
||||
unreadCount: 0,
|
||||
},
|
||||
{ moveToTop: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (isGroupMessage) {
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】非活跃群聊收到 WS 消息:累加群聊未读并更新顶部角标-----------
|
||||
incrementGroupUnread(conversationId, formatImMessagePreview(data.content, data.msgType), data.createTime as string);
|
||||
syncImUnreadFromMembers([...(getCachedMembers() || []), ...getCachedGroupUnreadItems()]);
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】非活跃群聊收到 WS 消息:累加群聊未读并更新顶部角标-----------
|
||||
} else {
|
||||
ensureCachedMember(senderId, {
|
||||
username: data.senderName as string,
|
||||
@@ -398,6 +533,10 @@ export function handleImChatSocket(data: Record<string, any>) {
|
||||
},
|
||||
{ unreadIncrement: 1 },
|
||||
);
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】单聊未读角标也合并群聊未读,保持角标一致性-----------
|
||||
syncImUnreadFromMembers([...(getCachedMembers() || []), ...getCachedGroupUnreadItems()]);
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】单聊未读角标也合并群聊未读,保持角标一致性-----------
|
||||
}
|
||||
syncImUnreadFromMembers(getCachedMembers() || []);
|
||||
dispatchImChatSocketUi(data);
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天-OA】单聊 WS 即时同步缓存并区分群聊-----------
|
||||
}
|
||||
|
||||
@@ -28,6 +28,22 @@ export function useImPageContext() {
|
||||
type ImOpenTargetListener = (targetUserId: string) => void | Promise<void>;
|
||||
const openTargetListeners = new Set<ImOpenTargetListener>();
|
||||
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】从消息提醒直接打开群聊会话-----------
|
||||
type ImOpenGroupListener = (conversationId: string) => void | Promise<void>;
|
||||
const openGroupListeners = new Set<ImOpenGroupListener>();
|
||||
|
||||
export function onImOpenGroupRequest(listener: ImOpenGroupListener) {
|
||||
openGroupListeners.add(listener);
|
||||
return () => openGroupListeners.delete(listener);
|
||||
}
|
||||
|
||||
async function notifyOpenGroup(conversationId: string) {
|
||||
for (const listener of openGroupListeners) {
|
||||
await listener(conversationId);
|
||||
}
|
||||
}
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】从消息提醒直接打开群聊会话-----------
|
||||
|
||||
export function setImChatPageActive(active: boolean) {
|
||||
imChatPageActive.value = active;
|
||||
}
|
||||
@@ -78,6 +94,10 @@ export function requestCloseImChatModal() {
|
||||
*/
|
||||
export async function openImChat(options?: {
|
||||
targetUserId?: string;
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】支持从消息提醒直接打开群聊-----------
|
||||
/** 群聊会话 ID(与 targetUserId 二选一) */
|
||||
conversationId?: string;
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】支持从消息提醒直接打开群聊-----------
|
||||
pageContext?: ImPageContext | null;
|
||||
}): Promise<'page' | 'modal'> {
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天-OA】弹窗打开时快照背后功能页名称-----------
|
||||
@@ -88,6 +108,10 @@ export async function openImChat(options?: {
|
||||
if (isImChatPageActive()) {
|
||||
if (options?.targetUserId) {
|
||||
await notifyOpenTarget(options.targetUserId);
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】全页 IM 时直接导航到指定群聊-----------
|
||||
} else if (options?.conversationId) {
|
||||
await notifyOpenGroup(options.conversationId);
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】全页 IM 时直接导航到指定群聊-----------
|
||||
}
|
||||
return 'page';
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ref } from 'vue';
|
||||
import { fetchDeptMembers } from './im.api';
|
||||
import {
|
||||
getCachedMembers,
|
||||
getCachedGroupUnreadItems,
|
||||
getImActiveConversationId,
|
||||
getImActiveTargetUserId,
|
||||
isImChatUiOpen,
|
||||
@@ -62,7 +63,9 @@ export async function refreshImUnread(force = false) {
|
||||
if (!force) {
|
||||
const cached = getCachedMembers();
|
||||
if (cached && !isMembersCacheStale()) {
|
||||
syncImUnreadFromMembers(cached);
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】从缓存刷新时也合并群聊未读-----------
|
||||
syncImUnreadFromMembers([...cached, ...getCachedGroupUnreadItems()]);
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】从缓存刷新时也合并群聊未读-----------
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -70,7 +73,9 @@ export async function refreshImUnread(force = false) {
|
||||
try {
|
||||
const members = ((await fetchDeptMembers()) || []) as Array<{ unreadCount?: number; conversationId?: string; id?: string }>;
|
||||
setCachedMembers(members);
|
||||
syncImUnreadFromMembers(members);
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】接口刷新时也合并群聊未读-----------
|
||||
syncImUnreadFromMembers([...members, ...getCachedGroupUnreadItems()]);
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】接口刷新时也合并群聊未读-----------
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
<div class="im-chat-msg-title">
|
||||
<span class="im-chat-msg-name">{{ item.realname || item.username }}</span>
|
||||
<span class="im-chat-msg-name">{{ item.displayName }}</span>
|
||||
<a-badge :count="item.unreadCount" :overflow-count="99" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,9 +18,14 @@
|
||||
</template>
|
||||
<template #avatar>
|
||||
<a-badge dot :offset="[-2, 2]">
|
||||
<a-avatar :src="getAvatarUrl(item.avatar)">
|
||||
{{ (item.realname || item.username || '?').slice(0, 1) }}
|
||||
<!--update-begin---author:xsl ---date:20260528 for:【IM聊天】群聊显示群图标,单聊显示头像------------->
|
||||
<a-avatar v-if="item.type === 'group'" :style="{ backgroundColor: '#1677ff' }">
|
||||
<Icon icon="ant-design:team-outlined" />
|
||||
</a-avatar>
|
||||
<a-avatar v-else :src="getAvatarUrl(item.avatar)">
|
||||
{{ (item.displayName || '?').slice(0, 1) }}
|
||||
</a-avatar>
|
||||
<!--update-end---author:xsl ---date:20260528 for:【IM聊天】群聊显示群图标,单聊显示头像----------->
|
||||
</a-badge>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
@@ -37,11 +42,13 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { useModal } from '/@/components/Modal';
|
||||
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
|
||||
import { fetchDeptMembers } from '/@/views/system/im/im.api';
|
||||
import { fetchDeptMembers, fetchGroups } from '/@/views/system/im/im.api';
|
||||
import { formatImMessagePreview } from '/@/views/system/im/imMessageUtil';
|
||||
import { syncImUnreadFromMembers } from '/@/views/system/im/useImUnread';
|
||||
import { getCachedGroupUnreadItems, initGroupUnreadFromList } from '/@/views/system/im/imCache';
|
||||
import ImChatModal from '/@/views/system/im/ImChatModal.vue';
|
||||
import { openImChat } from '/@/views/system/im/imSession';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
|
||||
defineOptions({ name: 'SysImChatMessageList' });
|
||||
|
||||
@@ -49,26 +56,59 @@
|
||||
(e: 'closeModal'): void;
|
||||
}>();
|
||||
|
||||
interface ChatMemberItem {
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】统一单聊与群聊展示类型-----------
|
||||
type ItemType = 'single' | 'group';
|
||||
|
||||
interface UnreadItem {
|
||||
type: ItemType;
|
||||
/** 单聊:userId;群聊:conversationId */
|
||||
id: string;
|
||||
username: string;
|
||||
realname?: string;
|
||||
displayName: string;
|
||||
avatar?: string;
|
||||
conversationId?: string;
|
||||
lastContent?: string;
|
||||
lastTime?: string;
|
||||
unreadCount?: number;
|
||||
}
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】统一单聊与群聊展示类型-----------
|
||||
|
||||
const loading = ref(false);
|
||||
const members = ref<ChatMemberItem[]>([]);
|
||||
const members = ref<any[]>([]);
|
||||
const groups = ref<any[]>([]);
|
||||
const [registerImChatModal, { openModal: openImChatModal }] = useModal();
|
||||
|
||||
const unreadList = computed(() =>
|
||||
members.value
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】合并单聊和群聊未读列表-----------
|
||||
const unreadList = computed<UnreadItem[]>(() => {
|
||||
const singleItems: UnreadItem[] = members.value
|
||||
.filter((item) => (item.unreadCount || 0) > 0)
|
||||
.sort((a, b) => dayjs(b.lastTime || 0).valueOf() - dayjs(a.lastTime || 0).valueOf()),
|
||||
);
|
||||
.map((item) => ({
|
||||
type: 'single',
|
||||
id: item.id,
|
||||
displayName: item.realname || item.username || '',
|
||||
avatar: item.avatar,
|
||||
conversationId: item.conversationId,
|
||||
lastContent: item.lastContent,
|
||||
lastTime: item.lastTime,
|
||||
unreadCount: item.unreadCount,
|
||||
}));
|
||||
|
||||
const groupItems: UnreadItem[] = groups.value
|
||||
.filter((item) => (item.unreadCount || 0) > 0)
|
||||
.map((item) => ({
|
||||
type: 'group',
|
||||
id: item.conversationId,
|
||||
displayName: item.groupName || '群聊',
|
||||
conversationId: item.conversationId,
|
||||
lastContent: item.lastContent,
|
||||
lastTime: item.lastTime,
|
||||
unreadCount: item.unreadCount,
|
||||
}));
|
||||
|
||||
return [...singleItems, ...groupItems].sort(
|
||||
(a, b) => dayjs(b.lastTime || 0).valueOf() - dayjs(a.lastTime || 0).valueOf(),
|
||||
);
|
||||
});
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】合并单聊和群聊未读列表-----------
|
||||
|
||||
const locale = computed(() => ({
|
||||
emptyText: loading.value ? ' ' : '暂无未读聊天消息',
|
||||
@@ -96,36 +136,52 @@
|
||||
return d.format('MM-DD HH:mm');
|
||||
}
|
||||
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】同时拉取单聊成员和群聊列表,修复 syncImUnreadFromMembers 丢失群聊未读-----------
|
||||
async function reload(silent = false) {
|
||||
//update-begin---author:cursor ---date:20250528 for:【IM聊天-OA】聊天消息列表静默刷新,避免 spin 遮罩闪烁-----------
|
||||
const showLoading = !silent && members.value.length === 0;
|
||||
const showLoading = !silent && members.value.length === 0 && groups.value.length === 0;
|
||||
if (showLoading) {
|
||||
loading.value = true;
|
||||
}
|
||||
try {
|
||||
members.value = ((await fetchDeptMembers()) || []) as ChatMemberItem[];
|
||||
syncImUnreadFromMembers(members.value);
|
||||
const [fetchedMembers, fetchedGroups] = await Promise.all([
|
||||
fetchDeptMembers().catch(() => []),
|
||||
fetchGroups().catch(() => []),
|
||||
]);
|
||||
members.value = (fetchedMembers || []) as any[];
|
||||
groups.value = (fetchedGroups || []) as any[];
|
||||
// 同步群聊未读到全局缓存
|
||||
initGroupUnreadFromList(groups.value);
|
||||
// 合并单聊 + 群聊未读数,防止 syncImUnreadFromMembers 只传成员数据导致群聊角标清零
|
||||
syncImUnreadFromMembers([...members.value, ...getCachedGroupUnreadItems()]);
|
||||
} catch {
|
||||
if (!silent) {
|
||||
members.value = [];
|
||||
groups.value = [];
|
||||
}
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
//update-end---author:cursor ---date:20250528 for:【IM聊天-OA】聊天消息列表静默刷新,避免 spin 遮罩闪烁-----------
|
||||
}
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】同时拉取单聊成员和群聊列表,修复 syncImUnreadFromMembers 丢失群聊未读-----------
|
||||
|
||||
async function handleOpenChat(item: ChatMemberItem) {
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天-OA】统一 IM 打开入口,全页优先-----------
|
||||
const mode = await openImChat({ targetUserId: item.id, pageContext: null });
|
||||
if (mode === 'modal') {
|
||||
openImChatModal(true, { targetUserId: item.id, pageContext: null });
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】点击群聊条目直接打开群聊会话-----------
|
||||
async function handleOpenChat(item: UnreadItem) {
|
||||
if (item.type === 'group') {
|
||||
const mode = await openImChat({ conversationId: item.conversationId });
|
||||
if (mode === 'modal') {
|
||||
openImChatModal(true, { conversationId: item.conversationId });
|
||||
}
|
||||
} else {
|
||||
const mode = await openImChat({ targetUserId: item.id, pageContext: null });
|
||||
if (mode === 'modal') {
|
||||
openImChatModal(true, { targetUserId: item.id, pageContext: null });
|
||||
}
|
||||
}
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天-OA】统一 IM 打开入口,全页优先-----------
|
||||
emit('closeModal');
|
||||
}
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】点击群聊条目直接打开群聊会话-----------
|
||||
|
||||
defineExpose({ reload });
|
||||
</script>
|
||||
|
||||
@@ -154,7 +154,9 @@
|
||||
import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent';
|
||||
import { useImUnread } from '/@/views/system/im/useImUnread';
|
||||
import { onWebSocket, offWebSocket, ensureWebSocketConnected } from '/@/hooks/web/useWebSocket';
|
||||
import { handleImChatSocket, isImChatUiOpen } from '/@/views/system/im/imCache';
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】只保留 isImChatUiOpen,handleImChatSocket 已移除(由 notify 统一调用)-----------
|
||||
import { isImChatUiOpen } from '/@/views/system/im/imCache';
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】只保留 isImChatUiOpen,handleImChatSocket 已移除(由 notify 统一调用)-----------
|
||||
import calendar from '/@/assets/icons/calendarNotice.png';
|
||||
import folder from '/@/assets/icons/folderNotice.png';
|
||||
import system from '/@/assets/icons/systemNotice.png';
|
||||
@@ -439,8 +441,12 @@
|
||||
|
||||
function onModalWebSocket(data) {
|
||||
if (data.cmd === 'chat') {
|
||||
handleImChatSocket(data);
|
||||
//update-begin---author:xsl ---date:20260528 for:【IM聊天】SysMessageModal 与 notify/index.vue 同时监听 WS,handleImChatSocket 已由 notify 处理,此处只做 UI 刷新-----------
|
||||
// handleImChatSocket 已在 notify/index.vue 的 onWebSocketMessage 中调用
|
||||
// 如果在此重复调用,incrementGroupUnread 和 dispatchImChatSocketUi 会触发两次
|
||||
// 导致 patchGroup(unreadIncrement:1) 执行两次,群聊 tab 角标变为 2(实际只有 1 条消息)
|
||||
refreshImUnread(false);
|
||||
//update-end---author:xsl ---date:20260528 for:【IM聊天】SysMessageModal 与 notify/index.vue 同时监听 WS,handleImChatSocket 已由 notify 处理,此处只做 UI 刷新-----------
|
||||
if (isImChatUiOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user