新增IM聊天

This commit is contained in:
geht
2026-05-28 14:37:05 +08:00
parent 99e574f600
commit 3539eab924
35 changed files with 2864 additions and 36 deletions

View 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>

View 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>

View 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>

View 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' });

View 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() || []);
}

View 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'));
}

View 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,
};
}