新增IM聊天
This commit is contained in:
993
jeecgboot-vue3/src/views/system/im/ImChat.vue
Normal file
993
jeecgboot-vue3/src/views/system/im/ImChat.vue
Normal file
@@ -0,0 +1,993 @@
|
||||
<template>
|
||||
<div :class="['im-chat-page', { 'im-chat-page--embedded': embedded }]">
|
||||
<div ref="chatRowRef" class="im-chat-row">
|
||||
<aside :class="['im-chat-left', { collapsed: leftCollapsed }]" :style="leftPanelStyle">
|
||||
<div class="im-chat-left-header">
|
||||
<div v-show="!leftCollapsed" class="header-main">
|
||||
<span class="title">IM聊天</span>
|
||||
<div class="dept-tip">{{ deptLabel }}</div>
|
||||
</div>
|
||||
<a-tooltip :title="leftCollapsed ? '展开列表' : '折叠列表'">
|
||||
<button type="button" class="collapse-btn" @click="toggleLeftCollapse">
|
||||
<Icon :icon="leftCollapsed ? 'ant-design:menu-unfold-outlined' : 'ant-design:menu-fold-outlined'" />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div v-show="!leftCollapsed" class="im-search-wrap">
|
||||
<a-input
|
||||
v-model:value="memberKeyword"
|
||||
placeholder="搜索同事"
|
||||
allow-clear
|
||||
size="small"
|
||||
class="im-search"
|
||||
@pressEnter="loadDeptMembers"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon icon="ant-design:search-outlined" class="im-search-icon" @click="loadDeptMembers" />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
<a-spin :spinning="memberLoading" class="left-spin">
|
||||
<div class="conv-list">
|
||||
<a-tooltip
|
||||
v-for="item in deptMembers"
|
||||
:key="item.id"
|
||||
:title="leftCollapsed ? item.realname || item.username : ''"
|
||||
placement="right"
|
||||
>
|
||||
<div
|
||||
:class="['conv-item', activeTargetUserId === item.id ? 'active' : '']"
|
||||
@click="selectMember(item)"
|
||||
>
|
||||
<a-badge :count="shouldShowUnread(item) ? item.unreadCount : 0" :offset="[-2, 2]">
|
||||
<a-avatar :size="leftCollapsed ? 36 : 40" :src="getAvatarUrl(item.avatar)">
|
||||
{{ (item.realname || item.username || '?').slice(0, 1) }}
|
||||
</a-avatar>
|
||||
</a-badge>
|
||||
<div v-show="!leftCollapsed" class="conv-meta">
|
||||
<div class="conv-top">
|
||||
<span class="conv-name">{{ item.realname || item.username }}</span>
|
||||
<span class="conv-time">{{ formatTime(item.lastTime) }}</span>
|
||||
</div>
|
||||
<div class="conv-bottom">
|
||||
<span class="conv-preview">{{ item.lastContent || '点击开始聊天' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-empty v-if="!deptMembers.length && !leftCollapsed" description="本部门暂无其他同事" />
|
||||
</div>
|
||||
</a-spin>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
v-show="!leftCollapsed"
|
||||
class="im-resize-handle"
|
||||
:class="{ dragging: isResizing }"
|
||||
@mousedown="startResize"
|
||||
/>
|
||||
|
||||
<main class="im-chat-right">
|
||||
<div class="im-chat-right-header">
|
||||
<span class="chat-peer-name">
|
||||
{{ activeMember ? activeMember.realname || activeMember.username : 'IM聊天' }}
|
||||
</span>
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<button type="button" class="chat-settings-btn" @click.prevent>
|
||||
<Icon icon="ant-design:setting-outlined" />
|
||||
</button>
|
||||
<template #overlay>
|
||||
<a-menu @click="handleSettingsMenuClick">
|
||||
<a-menu-item key="chatSettings">聊天设置</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
<template v-if="activeMember">
|
||||
<div ref="messageBoxRef" class="message-box" @scroll="handleMessageBoxScroll">
|
||||
<div v-if="msgLoading && messageList.length" class="load-more-hint">
|
||||
<a-spin size="small" />
|
||||
<span>加载更早的消息...</span>
|
||||
</div>
|
||||
<div v-else-if="hasMore && !messageList.length" class="load-more">
|
||||
<a-button type="link" size="small" :loading="msgLoading" @click="loadMoreMessages">查看更早的消息</a-button>
|
||||
</div>
|
||||
<div v-for="msg in messageList" :key="msg.id" :class="['message-row', msg.mine ? 'mine' : 'other']">
|
||||
<a-avatar :size="32" :src="getAvatarUrl(msg.senderAvatar)">
|
||||
{{ (msg.senderName || '?').slice(0, 1) }}
|
||||
</a-avatar>
|
||||
<div class="message-bubble">
|
||||
<div class="message-name" v-if="!msg.mine">{{ msg.senderName }}</div>
|
||||
<div class="message-content">{{ msg.content }}</div>
|
||||
<div class="message-time">{{ formatTime(msg.createTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-input">
|
||||
<a-textarea
|
||||
v-model:value="draft"
|
||||
:rows="3"
|
||||
placeholder="输入消息,Enter发送,Shift+Enter换行"
|
||||
@pressEnter="handlePressEnter"
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<a-button type="primary" :loading="sending" @click="handleSend">发送</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-empty v-else class="empty-chat" description="请从左侧选择本部门同事开始聊天" />
|
||||
</main>
|
||||
</div>
|
||||
<ImChatSettingsModal @register="registerSettingsModal" @saved="onChatSettingsSaved" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { ensureWebSocketConnected, onWebSocket, offWebSocket } from '/@/hooks/web/useWebSocket';
|
||||
import { useUserStore } from '/@/store/modules/user';
|
||||
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
|
||||
import { fetchDeptMembers, openConversation, fetchMessages, sendMessage, markRead } from './im.api';
|
||||
import { getImDefaultHistoryDays, getImDefaultStartTime, isWithinDefaultHistoryRange } from './imSettings';
|
||||
import ImChatSettingsModal from './ImChatSettingsModal.vue';
|
||||
import { useModal } from '/@/components/Modal';
|
||||
import { syncImUnreadFromMembers } from './useImUnread';
|
||||
import {
|
||||
type ImMemberItem,
|
||||
type ImMessageItem,
|
||||
getCachedMembers,
|
||||
isMembersCacheStale,
|
||||
setCachedMembers,
|
||||
getCachedMessages,
|
||||
isMessagesCacheStale,
|
||||
setCachedMessages,
|
||||
appendCachedMessage,
|
||||
patchCachedMember,
|
||||
prefetchImChatData,
|
||||
setImActiveConversationId,
|
||||
} from './imCache';
|
||||
|
||||
defineOptions({ name: 'ImChat' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 嵌入弹窗模式,高度自适应容器 */
|
||||
embedded?: boolean;
|
||||
}>(),
|
||||
{
|
||||
embedded: false,
|
||||
},
|
||||
);
|
||||
|
||||
interface DeptMemberItem extends ImMemberItem {}
|
||||
|
||||
interface MessageItem extends ImMessageItem {}
|
||||
|
||||
const userStore = useUserStore();
|
||||
const [registerSettingsModal, { openModal: openSettingsModal }] = useModal();
|
||||
const defaultHistoryDays = ref(getImDefaultHistoryDays());
|
||||
const currentUserId = computed(() => userStore.getUserInfo?.id || '');
|
||||
const deptLabel = computed(() => {
|
||||
const info = userStore.getUserInfo;
|
||||
return info?.orgCodeTxt || info?.orgCode || '本部门同事';
|
||||
});
|
||||
|
||||
const memberLoading = ref(false);
|
||||
const msgLoading = ref(false);
|
||||
const sending = ref(false);
|
||||
const memberKeyword = ref('');
|
||||
const draft = ref('');
|
||||
const deptMembers = ref<DeptMemberItem[]>([]);
|
||||
const messageList = ref<MessageItem[]>([]);
|
||||
const activeTargetUserId = ref('');
|
||||
const activeMember = ref<DeptMemberItem | null>(null);
|
||||
const activeConversationId = ref('');
|
||||
const messageBoxRef = ref<HTMLElement>();
|
||||
const pageSize = 20;
|
||||
const hasMore = ref(false);
|
||||
let loadMessagesSeq = 0;
|
||||
let scrollLoading = false;
|
||||
|
||||
const chatRowRef = ref<HTMLElement>();
|
||||
const LEFT_WIDTH_KEY = 'im-chat-left-width';
|
||||
const LEFT_COLLAPSED_KEY = 'im-chat-left-collapsed';
|
||||
const COLLAPSED_WIDTH = 56;
|
||||
const MIN_LEFT_WIDTH = 220;
|
||||
const MAX_LEFT_WIDTH = 420;
|
||||
const DEFAULT_LEFT_WIDTH = 280;
|
||||
|
||||
const leftWidth = ref(DEFAULT_LEFT_WIDTH);
|
||||
const leftCollapsed = ref(false);
|
||||
const isResizing = ref(false);
|
||||
let resizeStartX = 0;
|
||||
let resizeStartWidth = 0;
|
||||
|
||||
const leftPanelStyle = computed(() => ({
|
||||
width: `${leftCollapsed.value ? COLLAPSED_WIDTH : leftWidth.value}px`,
|
||||
}));
|
||||
|
||||
function loadLeftPanelPreference() {
|
||||
const savedWidth = Number(localStorage.getItem(LEFT_WIDTH_KEY));
|
||||
if (!Number.isNaN(savedWidth) && savedWidth >= MIN_LEFT_WIDTH && savedWidth <= MAX_LEFT_WIDTH) {
|
||||
leftWidth.value = savedWidth;
|
||||
}
|
||||
leftCollapsed.value = localStorage.getItem(LEFT_COLLAPSED_KEY) === '1';
|
||||
}
|
||||
|
||||
function saveLeftWidth() {
|
||||
localStorage.setItem(LEFT_WIDTH_KEY, String(leftWidth.value));
|
||||
}
|
||||
|
||||
function toggleLeftCollapse() {
|
||||
leftCollapsed.value = !leftCollapsed.value;
|
||||
localStorage.setItem(LEFT_COLLAPSED_KEY, leftCollapsed.value ? '1' : '0');
|
||||
}
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
if (leftCollapsed.value) {
|
||||
return;
|
||||
}
|
||||
isResizing.value = true;
|
||||
resizeStartX = e.clientX;
|
||||
resizeStartWidth = leftWidth.value;
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.addEventListener('mousemove', onResizeMove);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
}
|
||||
|
||||
function onResizeMove(e: MouseEvent) {
|
||||
if (!isResizing.value) {
|
||||
return;
|
||||
}
|
||||
const delta = e.clientX - resizeStartX;
|
||||
leftWidth.value = Math.min(MAX_LEFT_WIDTH, Math.max(MIN_LEFT_WIDTH, resizeStartWidth + delta));
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
if (!isResizing.value) {
|
||||
return;
|
||||
}
|
||||
isResizing.value = false;
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
document.removeEventListener('mousemove', onResizeMove);
|
||||
document.removeEventListener('mouseup', stopResize);
|
||||
saveLeftWidth();
|
||||
}
|
||||
|
||||
function getAvatarUrl(avatar?: string) {
|
||||
return avatar ? getFileAccessHttpUrl(avatar) : '';
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const d = dayjs(value);
|
||||
if (!d.isValid()) {
|
||||
return '';
|
||||
}
|
||||
if (d.isSame(dayjs(), 'day')) {
|
||||
return d.format('HH:mm');
|
||||
}
|
||||
return d.format('MM-DD HH:mm');
|
||||
}
|
||||
|
||||
/** 仅保留默认天数范围内的消息 */
|
||||
function filterDefaultRangeMessages(records: MessageItem[]) {
|
||||
return records.filter((item) => isWithinDefaultHistoryRange(item.createTime));
|
||||
}
|
||||
|
||||
function handleSettingsMenuClick({ key }: { key: string }) {
|
||||
if (key === 'chatSettings') {
|
||||
openSettingsModal(true, {});
|
||||
}
|
||||
}
|
||||
|
||||
function onChatSettingsSaved() {
|
||||
defaultHistoryDays.value = getImDefaultHistoryDays();
|
||||
if (activeConversationId.value) {
|
||||
loadMessages(true);
|
||||
}
|
||||
prefetchImChatData(true);
|
||||
}
|
||||
|
||||
async function loadDeptMembers(silent = false, force = false) {
|
||||
const keyword = memberKeyword.value.trim();
|
||||
let usedCache = false;
|
||||
|
||||
if (!keyword && !force) {
|
||||
const cached = getCachedMembers();
|
||||
if (cached?.length) {
|
||||
deptMembers.value = cached;
|
||||
syncImUnreadFromMembers(cached);
|
||||
syncActiveMember();
|
||||
usedCache = true;
|
||||
if (!isMembersCacheStale()) {
|
||||
return;
|
||||
}
|
||||
silent = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!silent && !usedCache) {
|
||||
memberLoading.value = true;
|
||||
}
|
||||
try {
|
||||
deptMembers.value = (await fetchDeptMembers(keyword || undefined)) || [];
|
||||
if (!keyword) {
|
||||
setCachedMembers(deptMembers.value);
|
||||
}
|
||||
syncImUnreadFromMembers(deptMembers.value);
|
||||
syncActiveMember();
|
||||
} finally {
|
||||
if (!silent && !usedCache) {
|
||||
memberLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前会话不展示未读角标 */
|
||||
function shouldShowUnread(item: DeptMemberItem) {
|
||||
return (item.unreadCount || 0) > 0 && activeTargetUserId.value !== item.id;
|
||||
}
|
||||
|
||||
/** 无感更新左侧列表项(不请求接口、不触发 loading) */
|
||||
function patchDeptMember(
|
||||
userId: string,
|
||||
patch: Partial<DeptMemberItem>,
|
||||
options?: { moveToTop?: boolean; unreadIncrement?: number },
|
||||
) {
|
||||
const index = deptMembers.value.findIndex((item) => item.id === userId);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
const current = deptMembers.value[index];
|
||||
const updated: DeptMemberItem = {
|
||||
...current,
|
||||
...patch,
|
||||
};
|
||||
if (options?.unreadIncrement) {
|
||||
updated.unreadCount = (current.unreadCount || 0) + options.unreadIncrement;
|
||||
}
|
||||
const list = deptMembers.value.slice();
|
||||
list.splice(index, 1);
|
||||
if (options?.moveToTop !== false) {
|
||||
list.unshift(updated);
|
||||
} else {
|
||||
list.splice(index, 0, updated);
|
||||
}
|
||||
deptMembers.value = list;
|
||||
patchCachedMember(userId, patch, options);
|
||||
syncImUnreadFromMembers(deptMembers.value);
|
||||
if (activeTargetUserId.value === userId) {
|
||||
activeMember.value = { ...(activeMember.value || updated), ...updated };
|
||||
}
|
||||
}
|
||||
|
||||
function syncActiveMember() {
|
||||
if (!activeTargetUserId.value) {
|
||||
return;
|
||||
}
|
||||
const found = deptMembers.value.find((item) => item.id === activeTargetUserId.value);
|
||||
if (found) {
|
||||
activeMember.value = found;
|
||||
if (found.conversationId) {
|
||||
activeConversationId.value = found.conversationId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否还有更早的消息可加载 */
|
||||
function updateHasMore(latestBatch: MessageItem[]) {
|
||||
if (latestBatch.length >= pageSize) {
|
||||
hasMore.value = true;
|
||||
return;
|
||||
}
|
||||
const lastTime = activeMember.value?.lastTime;
|
||||
if (!lastTime) {
|
||||
hasMore.value = false;
|
||||
return;
|
||||
}
|
||||
const oldestLoaded = messageList.value[0]?.createTime;
|
||||
if (!oldestLoaded) {
|
||||
hasMore.value = dayjs(lastTime).isBefore(dayjs().subtract(defaultHistoryDays.value, 'day'));
|
||||
return;
|
||||
}
|
||||
hasMore.value = dayjs(lastTime).isBefore(dayjs(oldestLoaded));
|
||||
}
|
||||
|
||||
async function loadMessages(reset = true) {
|
||||
if (!activeConversationId.value) {
|
||||
messageList.value = [];
|
||||
return;
|
||||
}
|
||||
const conversationId = activeConversationId.value;
|
||||
const requestSeq = ++loadMessagesSeq;
|
||||
let displayedFromCache = false;
|
||||
|
||||
if (reset) {
|
||||
const cached = getCachedMessages(conversationId);
|
||||
if (cached?.records.length) {
|
||||
messageList.value = filterDefaultRangeMessages(cached.records);
|
||||
updateHasMore(messageList.value);
|
||||
displayedFromCache = true;
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
markRead(conversationId).catch(() => {});
|
||||
if (activeTargetUserId.value) {
|
||||
patchDeptMember(activeTargetUserId.value, { unreadCount: 0 }, { moveToTop: false });
|
||||
}
|
||||
if (!isMessagesCacheStale(conversationId)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
messageList.value = [];
|
||||
updateHasMore([]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!displayedFromCache) {
|
||||
msgLoading.value = true;
|
||||
}
|
||||
try {
|
||||
const page = await fetchMessages(
|
||||
reset
|
||||
? { conversationId, pageSize, startTime: getImDefaultStartTime() }
|
||||
: {
|
||||
conversationId,
|
||||
pageSize,
|
||||
beforeTime: messageList.value.length > 0 ? messageList.value[0].createTime : getImDefaultStartTime(),
|
||||
},
|
||||
);
|
||||
if (requestSeq !== loadMessagesSeq || conversationId !== activeConversationId.value) {
|
||||
return;
|
||||
}
|
||||
const records: MessageItem[] = page?.records || [];
|
||||
if (reset) {
|
||||
if (records.length > 0 || !displayedFromCache) {
|
||||
messageList.value = records;
|
||||
setCachedMessages(conversationId, records, records.length >= pageSize, {
|
||||
allowEmpty: records.length === 0,
|
||||
});
|
||||
}
|
||||
updateHasMore(records.length > 0 ? records : messageList.value);
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
markRead(conversationId).catch(() => {});
|
||||
if (activeTargetUserId.value) {
|
||||
patchDeptMember(activeTargetUserId.value, { unreadCount: 0 }, { moveToTop: false });
|
||||
}
|
||||
} else if (records.length > 0) {
|
||||
messageList.value = [...records, ...messageList.value];
|
||||
updateHasMore(records);
|
||||
} else {
|
||||
hasMore.value = false;
|
||||
}
|
||||
} catch {
|
||||
// 请求失败时保留已展示的内容
|
||||
} finally {
|
||||
if (requestSeq === loadMessagesSeq) {
|
||||
msgLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreMessages() {
|
||||
if (!activeConversationId.value || msgLoading.value || !hasMore.value) {
|
||||
return;
|
||||
}
|
||||
const box = messageBoxRef.value;
|
||||
const prevScrollHeight = box?.scrollHeight || 0;
|
||||
await loadMessages(false);
|
||||
await nextTick();
|
||||
if (box) {
|
||||
box.scrollTop = box.scrollHeight - prevScrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessageBoxScroll() {
|
||||
const box = messageBoxRef.value;
|
||||
if (!box || msgLoading.value || !hasMore.value || scrollLoading) {
|
||||
return;
|
||||
}
|
||||
if (box.scrollTop > 80) {
|
||||
return;
|
||||
}
|
||||
scrollLoading = true;
|
||||
try {
|
||||
await loadMoreMessages();
|
||||
} finally {
|
||||
scrollLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectMember(item: DeptMemberItem) {
|
||||
if (activeTargetUserId.value === item.id && activeConversationId.value) {
|
||||
return;
|
||||
}
|
||||
activeTargetUserId.value = item.id;
|
||||
activeMember.value = item;
|
||||
|
||||
if (item.conversationId) {
|
||||
activeConversationId.value = item.conversationId;
|
||||
setImActiveConversationId(item.conversationId);
|
||||
await loadMessages(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const conv = await openConversation(item.id);
|
||||
activeConversationId.value = conv.conversationId;
|
||||
setImActiveConversationId(conv.conversationId);
|
||||
activeMember.value = {
|
||||
...item,
|
||||
conversationId: conv.conversationId,
|
||||
lastContent: conv.lastContent,
|
||||
lastTime: conv.lastTime,
|
||||
unreadCount: 0,
|
||||
};
|
||||
patchDeptMember(item.id, {
|
||||
conversationId: conv.conversationId,
|
||||
lastContent: conv.lastContent,
|
||||
lastTime: conv.lastTime,
|
||||
unreadCount: 0,
|
||||
}, { moveToTop: false });
|
||||
await loadMessages(true);
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const el = messageBoxRef.value;
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const content = draft.value.trim();
|
||||
if (!content || !activeConversationId.value) {
|
||||
return;
|
||||
}
|
||||
sending.value = true;
|
||||
try {
|
||||
const msg = await sendMessage({ conversationId: activeConversationId.value, content, msgType: 'text' });
|
||||
messageList.value.push(msg);
|
||||
appendCachedMessage(activeConversationId.value, msg);
|
||||
draft.value = '';
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
patchDeptMember(activeTargetUserId.value, {
|
||||
conversationId: activeConversationId.value,
|
||||
lastContent: msg.content,
|
||||
lastTime: msg.createTime,
|
||||
unreadCount: 0,
|
||||
});
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePressEnter(e: KeyboardEvent) {
|
||||
if (e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
|
||||
function onChatSocket(data: Record<string, any>) {
|
||||
if (data.cmd !== 'chat') {
|
||||
return;
|
||||
}
|
||||
const conversationId = data.conversationId as string;
|
||||
const senderId = data.senderId as string;
|
||||
const isActiveConversation = !!conversationId && conversationId === activeConversationId.value;
|
||||
|
||||
if (isActiveConversation) {
|
||||
const exists = messageList.value.some((item) => item.id === data.messageId);
|
||||
if (!exists) {
|
||||
messageList.value.push({
|
||||
id: data.messageId,
|
||||
conversationId,
|
||||
senderId,
|
||||
senderName: data.senderName,
|
||||
senderAvatar: data.senderAvatar,
|
||||
content: data.content,
|
||||
msgType: data.msgType,
|
||||
mine: senderId === currentUserId.value,
|
||||
createTime: data.createTime,
|
||||
});
|
||||
appendCachedMessage(conversationId, messageList.value[messageList.value.length - 1]);
|
||||
nextTick(() => scrollToBottom());
|
||||
}
|
||||
markRead(conversationId);
|
||||
if (activeTargetUserId.value) {
|
||||
patchDeptMember(activeTargetUserId.value, {
|
||||
conversationId,
|
||||
lastContent: data.content,
|
||||
lastTime: data.createTime,
|
||||
unreadCount: 0,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 非当前会话:仅本地更新摘要与未读,不整表刷新
|
||||
patchDeptMember(
|
||||
senderId,
|
||||
{
|
||||
conversationId,
|
||||
lastContent: data.content,
|
||||
lastTime: data.createTime,
|
||||
},
|
||||
{ unreadIncrement: 1 },
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLeftPanelPreference();
|
||||
ensureWebSocketConnected();
|
||||
onWebSocket(onChatSocket);
|
||||
loadDeptMembers();
|
||||
prefetchImChatData();
|
||||
});
|
||||
|
||||
/** 弹窗再次打开时,若消息被并发请求清空则从缓存恢复 */
|
||||
function restoreSessionIfNeeded() {
|
||||
if (!activeConversationId.value || messageList.value.length > 0) {
|
||||
return;
|
||||
}
|
||||
const cached = getCachedMessages(activeConversationId.value);
|
||||
if (cached?.records.length) {
|
||||
messageList.value = filterDefaultRangeMessages(cached.records);
|
||||
updateHasMore(messageList.value);
|
||||
nextTick(() => scrollToBottom());
|
||||
return;
|
||||
}
|
||||
loadMessages(true);
|
||||
}
|
||||
|
||||
defineExpose({ restoreSessionIfNeeded });
|
||||
|
||||
onUnmounted(() => {
|
||||
offWebSocket(onChatSocket);
|
||||
setImActiveConversationId('');
|
||||
stopResize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.im-chat-page {
|
||||
height: calc(100vh - 120px);
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
|
||||
&--embedded {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.im-chat-row {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.im-chat-left {
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
transition: width 0.2s ease;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
|
||||
&.collapsed {
|
||||
.im-chat-left-header {
|
||||
justify-content: center;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
justify-content: center;
|
||||
padding: 10px 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.im-resize-handle {
|
||||
flex-shrink: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.dragging {
|
||||
background: rgba(22, 119, 255, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.im-chat-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.left-spin {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.ant-spin-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.im-chat-left-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 12px 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
|
||||
.header-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.dept-tip {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.im-search-wrap {
|
||||
padding: 8px 10px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.im-search {
|
||||
width: 100%;
|
||||
|
||||
:deep(.ant-input) {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.im-search-icon {
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.conv-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0 8px;
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background: #eef4ff;
|
||||
}
|
||||
}
|
||||
|
||||
.conv-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conv-top,
|
||||
.conv-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.conv-top {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.conv-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conv-time,
|
||||
.conv-preview,
|
||||
.message-time {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.conv-preview {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.im-chat-right-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-weight: 600;
|
||||
|
||||
.chat-peer-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-settings-btn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.message-box {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.load-more,
|
||||
.load-more-hint {
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.load-more-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&.mine {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.message-bubble {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 60%;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.message-name {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.empty-chat {
|
||||
margin-top: 120px;
|
||||
}
|
||||
</style>
|
||||
62
jeecgboot-vue3/src/views/system/im/ImChatModal.vue
Normal file
62
jeecgboot-vue3/src/views/system/im/ImChatModal.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
v-bind="$attrs"
|
||||
title="IM聊天"
|
||||
:width="980"
|
||||
:footer="null"
|
||||
:canFullscreen="true"
|
||||
:destroyOnClose="false"
|
||||
wrapClassName="im-chat-modal-wrap"
|
||||
@register="registerModal"
|
||||
@open-change="handleOpenChange"
|
||||
>
|
||||
<div class="im-chat-modal-body">
|
||||
<ImChat ref="imChatRef" embedded />
|
||||
</div>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, nextTick } from 'vue';
|
||||
import { BasicModal, useModalInner } from '/@/components/Modal';
|
||||
import ImChat from './ImChat.vue';
|
||||
|
||||
defineOptions({ name: 'ImChatModal' });
|
||||
|
||||
const imChatRef = ref<InstanceType<typeof ImChat>>();
|
||||
|
||||
function restoreChatSession() {
|
||||
nextTick(() => {
|
||||
imChatRef.value?.restoreSessionIfNeeded?.();
|
||||
});
|
||||
}
|
||||
|
||||
const [registerModal] = useModalInner(() => {
|
||||
restoreChatSession();
|
||||
});
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (open) {
|
||||
restoreChatSession();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.im-chat-modal-wrap {
|
||||
.im-chat-modal-body {
|
||||
height: 70vh;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.im-chat-page--embedded {
|
||||
height: 100% !important;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.im-chat-row {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
76
jeecgboot-vue3/src/views/system/im/ImChatSettingsModal.vue
Normal file
76
jeecgboot-vue3/src/views/system/im/ImChatSettingsModal.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
v-bind="$attrs"
|
||||
title="聊天设置"
|
||||
:width="480"
|
||||
@register="registerModal"
|
||||
@ok="handleSubmit"
|
||||
>
|
||||
<a-form layout="vertical" class="im-chat-settings-form">
|
||||
<a-form-item label="聊天记录默认天数">
|
||||
<a-input-number
|
||||
v-model:value="historyDays"
|
||||
:min="IM_HISTORY_DAYS_MIN"
|
||||
:max="IM_HISTORY_DAYS_MAX"
|
||||
:step="0.1"
|
||||
:precision="1"
|
||||
style="width: 100%"
|
||||
placeholder="0.1 ~ 7"
|
||||
/>
|
||||
<div class="form-tip">聊天页面默认展示最近 {{ displayDays }} 天的记录,向上滚动可加载更早消息</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, unref } from 'vue';
|
||||
import { BasicModal, useModalInner } from '/@/components/Modal';
|
||||
import {
|
||||
clampImHistoryDays,
|
||||
getImDefaultHistoryDays,
|
||||
IM_HISTORY_DAYS_MAX,
|
||||
IM_HISTORY_DAYS_MIN,
|
||||
setImDefaultHistoryDays,
|
||||
} from './imSettings';
|
||||
|
||||
defineOptions({ name: 'ImChatSettingsModal' });
|
||||
|
||||
const emit = defineEmits<{ saved: [] }>();
|
||||
|
||||
const historyDays = ref(getImDefaultHistoryDays());
|
||||
|
||||
const displayDays = computed(() => clampImHistoryDays(unref(historyDays)));
|
||||
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(() => {
|
||||
historyDays.value = getImDefaultHistoryDays();
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const days = clampImHistoryDays(unref(historyDays));
|
||||
if (days < IM_HISTORY_DAYS_MIN || days > IM_HISTORY_DAYS_MAX) {
|
||||
return;
|
||||
}
|
||||
setModalProps({ confirmLoading: true });
|
||||
try {
|
||||
setImDefaultHistoryDays(days);
|
||||
emit('saved');
|
||||
closeModal();
|
||||
} finally {
|
||||
setModalProps({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.im-chat-settings-form {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
41
jeecgboot-vue3/src/views/system/im/im.api.ts
Normal file
41
jeecgboot-vue3/src/views/system/im/im.api.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
|
||||
enum Api {
|
||||
deptMembers = '/sys/im/chat/deptMembers',
|
||||
open = '/sys/im/chat/open',
|
||||
messages = '/sys/im/chat/messages',
|
||||
send = '/sys/im/chat/send',
|
||||
read = '/sys/im/chat/read',
|
||||
}
|
||||
|
||||
export const fetchDeptMembers = (keyword?: string) => defHttp.get({ url: Api.deptMembers, params: { keyword } });
|
||||
|
||||
export const openConversation = (targetUserId: string) =>
|
||||
defHttp.post({ url: Api.open, params: { targetUserId } }, { joinParamsToUrl: true });
|
||||
|
||||
export interface FetchMessagesParams {
|
||||
conversationId: string;
|
||||
pageSize?: number;
|
||||
/** 起始时间(含),默认首屏传用户配置的默认天数 */
|
||||
startTime?: string;
|
||||
/** 加载更早消息:取该时间之前的记录 */
|
||||
beforeTime?: string;
|
||||
}
|
||||
|
||||
export const fetchMessages = (params: FetchMessagesParams) =>
|
||||
defHttp.get({
|
||||
url: Api.messages,
|
||||
params: {
|
||||
pageNo: 1,
|
||||
pageSize: params.pageSize ?? 20,
|
||||
conversationId: params.conversationId,
|
||||
startTime: params.startTime,
|
||||
beforeTime: params.beforeTime,
|
||||
},
|
||||
});
|
||||
|
||||
export const sendMessage = (data: { conversationId: string; content: string; msgType?: string }) =>
|
||||
defHttp.post({ url: Api.send, data });
|
||||
|
||||
export const markRead = (conversationId: string) =>
|
||||
defHttp.put({ url: Api.read, params: { conversationId } }, { joinParamsToUrl: true, successMessageMode: 'none' });
|
||||
299
jeecgboot-vue3/src/views/system/im/imCache.ts
Normal file
299
jeecgboot-vue3/src/views/system/im/imCache.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { createSessionStorage } from '/@/utils/cache';
|
||||
import { useUserStoreWithOut } from '/@/store/modules/user';
|
||||
import { fetchDeptMembers, fetchMessages } from './im.api';
|
||||
import { getImDefaultStartTime } from './imSettings';
|
||||
import { syncImUnreadFromMembers } from './useImUnread';
|
||||
|
||||
export interface ImMemberItem {
|
||||
id: string;
|
||||
username: string;
|
||||
realname?: string;
|
||||
avatar?: string;
|
||||
orgCodeTxt?: string;
|
||||
conversationId?: string;
|
||||
lastContent?: string;
|
||||
lastTime?: string;
|
||||
unreadCount?: number;
|
||||
}
|
||||
|
||||
export interface ImMessageItem {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
senderAvatar?: string;
|
||||
content: string;
|
||||
msgType?: string;
|
||||
mine?: boolean;
|
||||
createTime?: string;
|
||||
}
|
||||
|
||||
export interface ImMessageCacheEntry {
|
||||
records: ImMessageItem[];
|
||||
hasMore: boolean;
|
||||
loadedAt: number;
|
||||
}
|
||||
|
||||
interface ImCacheSnapshot {
|
||||
members: ImMemberItem[];
|
||||
membersLoadedAt: number;
|
||||
messages: Record<string, ImMessageCacheEntry>;
|
||||
}
|
||||
|
||||
const MEMBERS_STALE_MS = 60_000;
|
||||
const MESSAGES_STALE_MS = 30_000;
|
||||
const PREFETCH_CONV_LIMIT = 5;
|
||||
const MESSAGE_PAGE_SIZE = 20;
|
||||
const CACHE_KEY = 'im-chat-data';
|
||||
|
||||
const sessionCache = createSessionStorage({ timeout: 60 * 60 });
|
||||
|
||||
let memorySnapshot: ImCacheSnapshot | null = null;
|
||||
let prefetchPromise: Promise<void> | null = null;
|
||||
let activeConversationId = '';
|
||||
|
||||
function getCacheScopeKey(): string | null {
|
||||
const userStore = useUserStoreWithOut();
|
||||
const userId = userStore.getUserInfo?.id;
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
const tenantId = userStore.getTenant;
|
||||
return `${tenantId || '0'}:${userId}`;
|
||||
}
|
||||
|
||||
function loadFromSession(): ImCacheSnapshot | null {
|
||||
const scope = getCacheScopeKey();
|
||||
if (!scope) {
|
||||
return null;
|
||||
}
|
||||
return sessionCache.get(`${CACHE_KEY}:${scope}`) || null;
|
||||
}
|
||||
|
||||
function saveToSession(snapshot: ImCacheSnapshot) {
|
||||
const scope = getCacheScopeKey();
|
||||
if (!scope) {
|
||||
return;
|
||||
}
|
||||
sessionCache.set(`${CACHE_KEY}:${scope}`, snapshot);
|
||||
}
|
||||
|
||||
function ensureMemory(): ImCacheSnapshot {
|
||||
if (!memorySnapshot) {
|
||||
memorySnapshot = loadFromSession() || {
|
||||
members: [],
|
||||
membersLoadedAt: 0,
|
||||
messages: {},
|
||||
};
|
||||
}
|
||||
return memorySnapshot;
|
||||
}
|
||||
|
||||
export function setImActiveConversationId(conversationId: string) {
|
||||
activeConversationId = conversationId || '';
|
||||
}
|
||||
|
||||
export function clearImCache() {
|
||||
const scope = getCacheScopeKey();
|
||||
memorySnapshot = null;
|
||||
prefetchPromise = null;
|
||||
activeConversationId = '';
|
||||
if (scope) {
|
||||
sessionCache.remove(`${CACHE_KEY}:${scope}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedMembers(): ImMemberItem[] | null {
|
||||
const snap = ensureMemory();
|
||||
return snap.members.length ? snap.members : null;
|
||||
}
|
||||
|
||||
export function isMembersCacheStale(): boolean {
|
||||
const snap = ensureMemory();
|
||||
if (!snap.membersLoadedAt || !snap.members.length) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() - snap.membersLoadedAt > MEMBERS_STALE_MS;
|
||||
}
|
||||
|
||||
export function setCachedMembers(members: ImMemberItem[]) {
|
||||
const snap = ensureMemory();
|
||||
snap.members = members;
|
||||
snap.membersLoadedAt = Date.now();
|
||||
saveToSession(snap);
|
||||
}
|
||||
|
||||
export function getCachedMessages(conversationId: string): ImMessageCacheEntry | null {
|
||||
return ensureMemory().messages[conversationId] || null;
|
||||
}
|
||||
|
||||
export function isMessagesCacheStale(conversationId: string): boolean {
|
||||
const entry = getCachedMessages(conversationId);
|
||||
if (!entry) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() - entry.loadedAt > MESSAGES_STALE_MS;
|
||||
}
|
||||
|
||||
export function setCachedMessages(
|
||||
conversationId: string,
|
||||
records: ImMessageItem[],
|
||||
hasMore: boolean,
|
||||
options?: { allowEmpty?: boolean },
|
||||
) {
|
||||
const existing = getCachedMessages(conversationId);
|
||||
if (!options?.allowEmpty && records.length === 0 && existing?.records?.length) {
|
||||
return;
|
||||
}
|
||||
const snap = ensureMemory();
|
||||
snap.messages[conversationId] = {
|
||||
records: [...records],
|
||||
hasMore,
|
||||
loadedAt: Date.now(),
|
||||
};
|
||||
saveToSession(snap);
|
||||
}
|
||||
|
||||
export function appendCachedMessage(conversationId: string, msg: ImMessageItem) {
|
||||
const snap = ensureMemory();
|
||||
if (!snap.messages[conversationId]) {
|
||||
snap.messages[conversationId] = {
|
||||
records: [],
|
||||
hasMore: false,
|
||||
loadedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
const entry = snap.messages[conversationId];
|
||||
if (entry.records.some((item) => item.id === msg.id)) {
|
||||
return;
|
||||
}
|
||||
entry.records.push(msg);
|
||||
entry.loadedAt = Date.now();
|
||||
saveToSession(snap);
|
||||
}
|
||||
|
||||
export function patchCachedMember(
|
||||
userId: string,
|
||||
patch: Partial<ImMemberItem>,
|
||||
options?: { moveToTop?: boolean; unreadIncrement?: number },
|
||||
) {
|
||||
const snap = ensureMemory();
|
||||
const index = snap.members.findIndex((item) => item.id === userId);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
const current = snap.members[index];
|
||||
const updated: ImMemberItem = {
|
||||
...current,
|
||||
...patch,
|
||||
};
|
||||
if (options?.unreadIncrement) {
|
||||
updated.unreadCount = (current.unreadCount || 0) + options.unreadIncrement;
|
||||
}
|
||||
const list = snap.members.slice();
|
||||
list.splice(index, 1);
|
||||
if (options?.moveToTop !== false) {
|
||||
list.unshift(updated);
|
||||
} else {
|
||||
list.splice(index, 0, updated);
|
||||
}
|
||||
snap.members = list;
|
||||
saveToSession(snap);
|
||||
}
|
||||
|
||||
async function prefetchConversationMessages(conversationId: string) {
|
||||
const cached = getCachedMessages(conversationId);
|
||||
if (cached && !isMessagesCacheStale(conversationId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const page = await fetchMessages({
|
||||
conversationId,
|
||||
pageSize: MESSAGE_PAGE_SIZE,
|
||||
startTime: getImDefaultStartTime(),
|
||||
});
|
||||
const records: ImMessageItem[] = page?.records || [];
|
||||
if (records.length > 0) {
|
||||
setCachedMessages(conversationId, records, records.length >= MESSAGE_PAGE_SIZE);
|
||||
}
|
||||
} catch {
|
||||
// 预取失败不影响主流程,保留已有缓存
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录后/进入系统后异步预取 IM 数据(同事列表 + 最近会话首屏消息) */
|
||||
export async function prefetchImChatData(force = false): Promise<void> {
|
||||
if (prefetchPromise && !force) {
|
||||
return prefetchPromise;
|
||||
}
|
||||
prefetchPromise = (async () => {
|
||||
try {
|
||||
const members = ((await fetchDeptMembers()) || []) as ImMemberItem[];
|
||||
setCachedMembers(members);
|
||||
syncImUnreadFromMembers(members);
|
||||
|
||||
const candidates = members
|
||||
.filter((item) => item.conversationId && (item.unreadCount || item.lastTime))
|
||||
.sort((a, b) => dayjs(b.lastTime || 0).valueOf() - dayjs(a.lastTime || 0).valueOf())
|
||||
.slice(0, PREFETCH_CONV_LIMIT);
|
||||
|
||||
for (let i = 0; i < candidates.length; i += 3) {
|
||||
const batch = candidates.slice(i, i + 3);
|
||||
await Promise.all(batch.map((item) => prefetchConversationMessages(item.conversationId!)));
|
||||
}
|
||||
} catch {
|
||||
// 静默失败,进入聊天页时会重新拉取
|
||||
} finally {
|
||||
prefetchPromise = null;
|
||||
}
|
||||
})();
|
||||
return prefetchPromise;
|
||||
}
|
||||
|
||||
/** 顶部角标:收到新消息时更新缓存未读数 */
|
||||
export function handleImChatSocket(data: Record<string, any>) {
|
||||
if (data.cmd !== 'chat') {
|
||||
return;
|
||||
}
|
||||
const conversationId = data.conversationId as string;
|
||||
const senderId = data.senderId as string;
|
||||
const isActiveConversation = !!conversationId && conversationId === activeConversationId;
|
||||
|
||||
if (isActiveConversation) {
|
||||
const userStore = useUserStoreWithOut();
|
||||
const currentUserId = userStore.getUserInfo?.id || '';
|
||||
appendCachedMessage(conversationId, {
|
||||
id: data.messageId,
|
||||
conversationId,
|
||||
senderId,
|
||||
senderName: data.senderName,
|
||||
senderAvatar: data.senderAvatar,
|
||||
content: data.content,
|
||||
msgType: data.msgType,
|
||||
mine: senderId === currentUserId,
|
||||
createTime: data.createTime,
|
||||
});
|
||||
patchCachedMember(
|
||||
senderId,
|
||||
{
|
||||
conversationId,
|
||||
lastContent: data.content,
|
||||
lastTime: data.createTime,
|
||||
unreadCount: 0,
|
||||
},
|
||||
{ moveToTop: true },
|
||||
);
|
||||
} else {
|
||||
patchCachedMember(
|
||||
senderId,
|
||||
{
|
||||
conversationId,
|
||||
lastContent: data.content,
|
||||
lastTime: data.createTime,
|
||||
},
|
||||
{ unreadIncrement: 1 },
|
||||
);
|
||||
}
|
||||
syncImUnreadFromMembers(getCachedMembers() || []);
|
||||
}
|
||||
48
jeecgboot-vue3/src/views/system/im/imSettings.ts
Normal file
48
jeecgboot-vue3/src/views/system/im/imSettings.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { useUserStoreWithOut } from '/@/store/modules/user';
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'im-chat-default-days';
|
||||
const DEFAULT_DAYS = 7;
|
||||
|
||||
export const IM_HISTORY_DAYS_MIN = 0.1;
|
||||
export const IM_HISTORY_DAYS_MAX = 7;
|
||||
|
||||
export function clampImHistoryDays(days: number): number {
|
||||
const value = Number(days);
|
||||
if (Number.isNaN(value)) {
|
||||
return DEFAULT_DAYS;
|
||||
}
|
||||
return Math.min(IM_HISTORY_DAYS_MAX, Math.max(IM_HISTORY_DAYS_MIN, Math.round(value * 10) / 10));
|
||||
}
|
||||
|
||||
function getStorageKey(): string {
|
||||
const userStore = useUserStoreWithOut();
|
||||
const userId = userStore.getUserInfo?.id || 'anonymous';
|
||||
const tenantId = userStore.getTenant || '0';
|
||||
return `${STORAGE_KEY_PREFIX}:${tenantId}:${userId}`;
|
||||
}
|
||||
|
||||
/** 聊天记录默认展示天数(0.1~7) */
|
||||
export function getImDefaultHistoryDays(): number {
|
||||
const raw = localStorage.getItem(getStorageKey());
|
||||
if (!raw) {
|
||||
return DEFAULT_DAYS;
|
||||
}
|
||||
return clampImHistoryDays(Number(raw));
|
||||
}
|
||||
|
||||
export function setImDefaultHistoryDays(days: number) {
|
||||
localStorage.setItem(getStorageKey(), String(clampImHistoryDays(days)));
|
||||
}
|
||||
|
||||
/** 默认展示范围的起始时间 */
|
||||
export function getImDefaultStartTime(): string {
|
||||
return dayjs().subtract(getImDefaultHistoryDays(), 'day').format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
export function isWithinDefaultHistoryRange(time?: string): boolean {
|
||||
if (!time) {
|
||||
return false;
|
||||
}
|
||||
return !dayjs(time).isBefore(dayjs().subtract(getImDefaultHistoryDays(), 'day'));
|
||||
}
|
||||
39
jeecgboot-vue3/src/views/system/im/useImUnread.ts
Normal file
39
jeecgboot-vue3/src/views/system/im/useImUnread.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ref } from 'vue';
|
||||
import { fetchDeptMembers } from './im.api';
|
||||
import { getCachedMembers, isMembersCacheStale, setCachedMembers } from './imCache';
|
||||
|
||||
const totalUnread = ref(0);
|
||||
let refreshing = false;
|
||||
|
||||
export function syncImUnreadFromMembers(members: Array<{ unreadCount?: number }>) {
|
||||
totalUnread.value = (members || []).reduce((sum, item) => sum + (item.unreadCount || 0), 0);
|
||||
}
|
||||
|
||||
export async function refreshImUnread(force = false) {
|
||||
if (refreshing) {
|
||||
return;
|
||||
}
|
||||
if (!force) {
|
||||
const cached = getCachedMembers();
|
||||
if (cached && !isMembersCacheStale()) {
|
||||
syncImUnreadFromMembers(cached);
|
||||
return;
|
||||
}
|
||||
}
|
||||
refreshing = true;
|
||||
try {
|
||||
const members = await fetchDeptMembers();
|
||||
setCachedMembers(members || []);
|
||||
syncImUnreadFromMembers(members || []);
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useImUnread() {
|
||||
return {
|
||||
totalUnread,
|
||||
refreshImUnread,
|
||||
syncImUnreadFromMembers,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user