IM聊天功能优化

This commit is contained in:
geht
2026-05-28 17:08:34 +08:00
parent 3539eab924
commit a63cd6ad1a
29 changed files with 3565 additions and 203 deletions

View File

@@ -22,12 +22,14 @@ import org.jeecg.modules.im.vo.SysImConversationVO;
import org.jeecg.modules.im.vo.SysImMessageVO;
import org.jeecg.modules.message.websocket.WebSocket;
import org.jeecg.modules.system.entity.SysDepart;
import org.jeecg.modules.system.entity.SysPermission;
import org.jeecg.modules.system.entity.SysUser;
import org.jeecg.modules.system.entity.SysUserDepart;
import org.jeecg.modules.system.mapper.SysDepartMapper;
import org.jeecg.modules.system.mapper.SysUserDepartMapper;
import org.jeecg.modules.system.mapper.SysUserMapper;
import org.jeecg.modules.system.mapper.SysUserTenantMapper;
import org.jeecg.modules.system.service.ISysPermissionService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -50,7 +52,14 @@ public class SysImChatServiceImpl implements ISysImChatService {
private static final String CONV_TYPE_SINGLE = "single";
private static final String MSG_TYPE_TEXT = "text";
private static final String MSG_TYPE_IMAGE = "image";
private static final String MSG_TYPE_BIZ_RECORD = "biz_record";
private static final String MSG_IMAGE_PREVIEW = "[图片]";
private static final String MSG_BIZ_RECORD_PREVIEW = "[业务数据]";
private static final String IM_RECORD_QUERY_KEY = "imRecordId";
@Autowired
private ISysPermissionService sysPermissionService;
@Autowired
private SysImConversationMapper conversationMapper;
@Autowired
@@ -169,13 +178,16 @@ public class SysImChatServiceImpl implements ISysImChatService {
messageMapper.insert(message);
SysImConversation conversation = conversationMapper.selectById(dto.getConversationId());
conversation.setLastContent(truncate(message.getContent(), 200));
//update-begin---author:cursor ---date:20260528 for【IM聊天-OA】图片消息会话摘要-----------
conversation.setLastContent(truncate(resolveLastContent(message.getMsgType(), message.getContent()), 200));
//update-end---author:cursor ---date:20260528 for【IM聊天-OA】图片消息会话摘要-----------
conversation.setLastTime(now);
conversation.setUpdateTime(now);
conversationMapper.updateById(conversation);
memberMapper.incrementUnreadExceptSender(dto.getConversationId(), userId);
SysImMessageVO messageVo = toMessageVo(message, userId);
fillBizRecordReceiverPermission(messageVo, message, userId, new HashMap<>(4));
pushChatMessage(dto.getConversationId(), userId, messageVo);
return messageVo;
}
@@ -376,8 +388,11 @@ public class SysImChatServiceImpl implements ISysImChatService {
}
}
List<SysImMessageVO> result = new ArrayList<>(messages.size());
Map<String, Boolean> receiverPermissionCache = new HashMap<>(16);
for (SysImMessage message : messages) {
result.add(toMessageVo(message, currentUserId, userMap));
SysImMessageVO vo = toMessageVo(message, currentUserId, userMap);
fillBizRecordReceiverPermission(vo, message, currentUserId, receiverPermissionCache);
result.add(vo);
}
return result;
}
@@ -401,6 +416,97 @@ public class SysImChatServiceImpl implements ISysImChatService {
}
return vo;
}
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】发送方提示接收方无功能权限-----------
private void fillBizRecordReceiverPermission(SysImMessageVO vo, SysImMessage message, String currentUserId, Map<String, Boolean> cache) {
if (!Boolean.TRUE.equals(vo.getMine()) || !MSG_TYPE_BIZ_RECORD.equals(vo.getMsgType())) {
return;
}
String pagePath = extractBizRecordPagePath(vo.getContent());
if (oConvertUtils.isEmpty(pagePath)) {
return;
}
String peerUserId = resolvePeerUserId(message.getConversationId(), currentUserId);
if (oConvertUtils.isEmpty(peerUserId)) {
return;
}
String cacheKey = peerUserId + "|" + normalizeBizPagePath(pagePath);
Boolean hasPermission = cache.get(cacheKey);
if (hasPermission == null) {
hasPermission = hasUserPagePathPermission(peerUserId, pagePath);
cache.put(cacheKey, hasPermission);
}
vo.setReceiverHasBizPagePermission(hasPermission);
}
private String resolvePeerUserId(String conversationId, String currentUserId) {
List<SysImConversationMember> members = memberMapper.selectList(new LambdaQueryWrapper<SysImConversationMember>()
.eq(SysImConversationMember::getConversationId, conversationId));
if (members == null || members.isEmpty()) {
return null;
}
for (SysImConversationMember member : members) {
if (!currentUserId.equals(member.getUserId())) {
return member.getUserId();
}
}
return null;
}
private String extractBizRecordPagePath(String content) {
if (oConvertUtils.isEmpty(content)) {
return null;
}
try {
JSONObject obj = JSONObject.parseObject(content);
return obj.getString("pagePath");
} catch (Exception e) {
log.debug("解析业务明细 pagePath 失败: {}", e.getMessage());
return null;
}
}
private String normalizeBizPagePath(String pagePath) {
if (oConvertUtils.isEmpty(pagePath)) {
return "";
}
String path = pagePath.split("\\?")[0];
if (path.contains("&" + IM_RECORD_QUERY_KEY + "=") || path.contains("?" + IM_RECORD_QUERY_KEY + "=")) {
path = path.replaceAll("[?&]" + IM_RECORD_QUERY_KEY + "=[^&]*", "");
if (path.endsWith("?") || path.endsWith("&")) {
path = path.substring(0, path.length() - 1);
}
}
if (path.endsWith("/") && path.length() > 1) {
path = path.substring(0, path.length() - 1);
}
return path;
}
private boolean hasUserPagePathPermission(String userId, String pagePath) {
String targetPath = normalizeBizPagePath(pagePath);
if (oConvertUtils.isEmpty(userId) || oConvertUtils.isEmpty(targetPath)) {
return false;
}
List<SysPermission> permissions = sysPermissionService.queryByUser(userId);
if (permissions == null || permissions.isEmpty()) {
return false;
}
for (SysPermission permission : permissions) {
if (permission == null || oConvertUtils.isEmpty(permission.getUrl())) {
continue;
}
if (permission.getMenuType() != null && permission.getMenuType() == 2) {
continue;
}
String url = normalizeBizPagePath(permission.getUrl());
if (targetPath.equals(url)) {
return true;
}
}
return false;
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】发送方提示接收方无功能权限-----------
//update-end---author:cursor ---date:20260528 for【IM聊天-OA】消息列表批量填充发送人-----------
private void pushChatMessage(String conversationId, String senderId, SysImMessageVO messageVo) {
@@ -433,4 +539,34 @@ public class SysImChatServiceImpl implements ISysImChatService {
}
return content.length() <= maxLen ? content : content.substring(0, maxLen);
}
//update-begin---author:cursor ---date:20260528 for【IM聊天-OA】图片消息会话摘要-----------
private String resolveLastContent(String msgType, String content) {
if (MSG_TYPE_IMAGE.equals(msgType)) {
return MSG_IMAGE_PREVIEW;
}
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】业务明细消息会话摘要-----------
if (MSG_TYPE_BIZ_RECORD.equals(msgType)) {
return resolveBizRecordPreview(content);
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】业务明细消息会话摘要-----------
return content;
}
private String resolveBizRecordPreview(String content) {
if (oConvertUtils.isEmpty(content)) {
return MSG_BIZ_RECORD_PREVIEW;
}
try {
JSONObject obj = JSONObject.parseObject(content);
String pageTitle = obj.getString("pageTitle");
if (oConvertUtils.isNotEmpty(pageTitle)) {
return MSG_BIZ_RECORD_PREVIEW + pageTitle;
}
} catch (Exception e) {
log.debug("解析业务明细消息摘要失败: {}", e.getMessage());
}
return MSG_BIZ_RECORD_PREVIEW;
}
//update-end---author:cursor ---date:20260528 for【IM聊天-OA】图片消息会话摘要-----------
}

View File

@@ -30,6 +30,8 @@ public class SysImMessageVO {
private String msgType;
@Schema(description = "是否本人发送")
private Boolean mine;
@Schema(description = "业务明细接收方是否有对应功能权限(仅发送方可见)")
private Boolean receiverHasBizPagePermission;
@Schema(description = "发送时间")
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")

View File

@@ -0,0 +1,16 @@
.im-record-locate-row {
> td {
background: #fff7e6 !important;
animation: im-record-locate-flash 1.2s ease-in-out 2;
}
}
@keyframes im-record-locate-flash {
0%,
100% {
box-shadow: inset 0 0 0 9999px rgba(250, 173, 20, 0.08);
}
50% {
box-shadow: inset 0 0 0 9999px rgba(250, 173, 20, 0.22);
}
}

View File

@@ -3,6 +3,7 @@
@import 'public.less';
@import 'ant/index.less';
@import './theme.less';
@import './im-record-locate.less';
@import './entry.css';
input:-webkit-autofill {

View File

@@ -1,4 +1,5 @@
import { reactive, ref, Ref, unref } from 'vue';
import { reactive, ref, Ref, unref, onUnmounted, watch, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import { merge } from 'lodash-es';
import { DynamicProps } from '/#/utils';
import { BasicTableProps, TableActionType, useTable } from '/@/components/Table';
@@ -9,6 +10,16 @@ import { useMethods } from '/@/hooks/system/useMethods';
import { useDesign } from '/@/hooks/web/useDesign';
import { filterObj } from '/@/utils/common/compUtils';
import { isFunction } from '@/utils/is';
import { registerImPageListProvider } from '/@/views/system/im/imPageListRegistry';
import { buildImPageListSnapshot } from '/@/views/system/im/imPageListUtil';
import { IM_RECORD_QUERY_KEY } from '/@/views/system/im/imBizRecordMessage';
import {
IM_RECORD_LOCATE_CLEAR_EVENT,
IM_RECORD_LOCATE_EVENT,
removeImRecordQueryFromRoute,
resolveImLocateRecordId,
scrollToImRecordRowWithRetry,
} from '/@/views/system/im/imRecordLocate';
const { handleExportXls, handleImportXls } = useMethods();
// 定义 useListPage 方法所需参数
@@ -59,7 +70,168 @@ export function useListPage(options: ListPageOptions) {
const tableContext = useListTable(options.tableProps);
const [, { getForm, reload, setLoading, getColumns }, { selectedRowKeys }] = tableContext;
const route = useRoute();
const [, tableMethods, { selectedRowKeys }] = tableContext;
const { getForm, reload, setLoading, getColumns } = tableMethods;
const imHighlightRecordId = ref('');
let clearHighlightTimer: ReturnType<typeof setTimeout> | null = null;
let locatingRecordId = '';
onUnmounted(() => {
if (clearHighlightTimer) {
clearTimeout(clearHighlightTimer);
clearHighlightTimer = null;
}
});
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】列表页注册 IM 明细快照提供器-----------
onUnmounted(
registerImPageListProvider(() => {
const sourceColumns = tableMethods.getColumns?.() || options.tableProps?.columns || [];
return buildImPageListSnapshot({
title: (options.tableProps?.title as string) || '',
pagePath: route.fullPath,
rowKey: (options.tableProps?.rowKey as string) || 'id',
sourceColumns,
records: tableMethods.getDataSource?.() || [],
});
}),
);
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】列表页注册 IM 明细快照提供器-----------
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】IM 消息链接跳转后定位列表行-----------
function isTableReady() {
try {
tableMethods.getDataSource?.();
return true;
} catch {
return false;
}
}
function applyImRecordRowClassName() {
if (!isTableReady()) {
return;
}
const rowKey = (options.tableProps?.rowKey as string) || 'id';
tableMethods.setProps?.({
rowClassName: (record: Recordable) => {
if (imHighlightRecordId.value && String(record[rowKey]) === imHighlightRecordId.value) {
return 'im-record-locate-row';
}
return '';
},
});
}
/** 等待列表首屏数据加载(兼容 immediate:false + 左侧树页面) */
async function waitForLocateContext(maxWaitMs = 3500) {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
if (!isTableReady()) {
await new Promise((resolve) => setTimeout(resolve, 50));
continue;
}
const data = tableMethods.getDataSource?.() || [];
if (data.length > 0) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
return isTableReady();
}
function findRecordInTable(recordId: string) {
const rowKey = (options.tableProps?.rowKey as string) || 'id';
const data = tableMethods.getDataSource?.() || [];
return data.some((item) => String(item[rowKey]) === recordId);
}
function clearImRecordHighlight() {
imHighlightRecordId.value = '';
applyImRecordRowClassName();
}
function scheduleClearImRecordHighlight(delayMs = 3500) {
if (clearHighlightTimer) {
clearTimeout(clearHighlightTimer);
}
clearHighlightTimer = setTimeout(() => {
clearImRecordHighlight();
clearHighlightTimer = null;
}, delayMs);
}
async function applyImRecordHighlight(recordId: string) {
imHighlightRecordId.value = recordId;
applyImRecordRowClassName();
await nextTick();
await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)));
applyImRecordRowClassName();
await scrollToImRecordRowWithRetry(recordId);
}
async function locateImRecordRow(recordId: string) {
if (locatingRecordId === recordId) {
return;
}
locatingRecordId = recordId;
try {
if (!(await waitForLocateContext())) {
return;
}
if (!findRecordInTable(recordId)) {
$message.createMessage.warning('当前列表中未找到对应数据');
removeImRecordQueryFromRoute();
return;
}
await applyImRecordHighlight(recordId);
scheduleClearImRecordHighlight();
// 直链 URL 场景:仅改地址栏,避免 router.replace 导致 fullPath 变化 remount
removeImRecordQueryFromRoute();
} finally {
locatingRecordId = '';
}
}
watch(
() => [route.path, route.query[IM_RECORD_QUERY_KEY]] as const,
([path, queryRecordId]) => {
const recordId = resolveImLocateRecordId(path, queryRecordId);
if (!recordId) {
return;
}
nextTick(() => locateImRecordRow(recordId));
},
{ immediate: true },
);
function handleImRecordLocateEvent(e: Event) {
const detail = (e as CustomEvent<{ path: string; recordId: string }>).detail;
if (!detail?.path || detail.path !== route.path || !detail.recordId) {
return;
}
nextTick(() => locateImRecordRow(detail.recordId));
}
function handleImRecordLocateClearEvent() {
if (clearHighlightTimer) {
clearTimeout(clearHighlightTimer);
clearHighlightTimer = null;
}
locatingRecordId = '';
clearImRecordHighlight();
}
onUnmounted(() => {
window.removeEventListener(IM_RECORD_LOCATE_EVENT, handleImRecordLocateEvent);
window.removeEventListener(IM_RECORD_LOCATE_CLEAR_EVENT, handleImRecordLocateClearEvent);
});
window.addEventListener(IM_RECORD_LOCATE_EVENT, handleImRecordLocateEvent);
window.addEventListener(IM_RECORD_LOCATE_CLEAR_EVENT, handleImRecordLocateClearEvent);
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】IM 消息链接跳转后定位列表行-----------
// 导出 excel
async function onExportXls() {

View File

@@ -8,6 +8,7 @@ import { useRouter } from 'vue-router';
import { REDIRECT_NAME } from '/@/router/constant';
import { useUserStore } from '/@/store/modules/user';
import { useMultipleTabStore } from '/@/store/modules/multipleTab';
import { clearImRecordLocateState, stripImRecordQuery } from '/@/views/system/im/imRecordLocate';
export type RouteLocationRawEx = Omit<RouteLocationRaw, 'path'> & { path: PageEnum };
@@ -43,10 +44,17 @@ export function useGo(_router?: Router) {
* @description: redo current page
*/
export const useRedo = (_router?: Router, otherQuery?: Recordable) => {
const { push, currentRoute } = _router || useRouter();
const { query, params = {}, name, fullPath } = unref(currentRoute.value);
const router = _router || useRouter();
const { push, currentRoute, resolve: resolveRoute } = router;
function redo(): Promise<boolean> {
return new Promise((resolve) => {
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】标签页刷新时取消 IM 定位-----------
clearImRecordLocateState();
const rawRoute = unref(currentRoute.value);
let { query, params = {}, name, fullPath } = rawRoute;
query = stripImRecordQuery(query as Recordable);
fullPath = resolveRoute({ path: rawRoute.path, query, hash: rawRoute.hash }).fullPath;
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】标签页刷新时取消 IM 定位-----------
if (name === REDIRECT_NAME) {
resolve(false);
return;

View File

@@ -1,65 +1,47 @@
<template>
<div :class="prefixCls">
<Badge :count="totalUnread" :overflowCount="99" :offset="[-4, 18]" :numberStyle="numberStyle" @click="openChat">
<MessageOutlined />
</Badge>
<div :class="[prefixCls, { 'is-disabled': imPageActive }]" @click="openChat">
<MessageOutlined />
<ImChatModal @register="registerModal" />
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, onUnmounted } from 'vue';
import { Badge } from 'ant-design-vue';
import { defineComponent, onMounted } from 'vue';
import { MessageOutlined } from '@ant-design/icons-vue';
import { useModal } from '/@/components/Modal';
import { useDesign } from '/@/hooks/web/useDesign';
import { ensureWebSocketConnected, onWebSocket, offWebSocket } from '/@/hooks/web/useWebSocket';
import ImChatModal from '/@/views/system/im/ImChatModal.vue';
import { prefetchImChatData, handleImChatSocket } from '/@/views/system/im/imCache';
import { useImUnread } from '/@/views/system/im/useImUnread';
import { prefetchImChatData } from '/@/views/system/im/imCache';
import { useImChat } from '/@/views/system/im/useImChat';
import { refreshImUnread } from '/@/views/system/im/useImUnread';
import { useImChatPageActive } from '/@/views/system/im/imSession';
export default defineComponent({
name: 'HeaderImChat',
components: {
Badge,
MessageOutlined,
ImChatModal,
},
setup() {
const { prefixCls } = useDesign('header-im-chat');
const { totalUnread } = useImUnread();
const [registerModal, { openModal }] = useModal();
const numberStyle = {
fontSize: '12px',
height: '16px',
minWidth: '16px',
lineHeight: '16px',
padding: '0 4px',
};
const imPageActive = useImChatPageActive();
const { openChatModal } = useImChat();
function openChat() {
openModal(true, {});
}
function onImSocket(data: Record<string, any>) {
handleImChatSocket(data);
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】头部打开 IM 时快照当前功能页名称-----------
openChatModal(openModal);
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】头部打开 IM 时快照当前功能页名称-----------
}
onMounted(() => {
ensureWebSocketConnected();
prefetchImChatData();
onWebSocket(onImSocket);
});
onUnmounted(() => {
offWebSocket(onImSocket);
refreshImUnread(true);
});
return {
prefixCls,
totalUnread,
numberStyle,
imPageActive,
openChat,
registerModal,
};
@@ -73,22 +55,17 @@
.@{prefix-cls} {
cursor: pointer;
padding: 0 10px;
font-size: 18px;
display: inline-flex;
align-items: center;
.ant-badge {
font-size: 18px;
svg {
width: 0.9em;
}
.ant-badge-count {
@badget-size: 16px;
width: @badget-size;
height: @badget-size;
min-width: @badget-size;
line-height: @badget-size;
padding: 0;
}
svg {
width: 0.9em;
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.45;
}
}
</style>

View File

@@ -1,19 +1,19 @@
<template>
<div :class="prefixCls">
<Badge :count="messageCount" :overflowCount="9" :offset="[-4, 18]" :numberStyle="numberStyle" @click="clickBadge('')">
<Badge :count="messageCount" :overflowCount="99" :offset="[-4, 18]" :numberStyle="numberStyle" @click="clickBadge('')">
<BellOutlined />
</Badge>
<DynamicNotice ref="dynamicNoticeRef" v-bind="dynamicNoticeProps" />
<DetailModal @register="registerDetail" />
<sys-message-modal @register="registerMessageModal" @refresh="reloadCount" :messageCount="messageCount"></sys-message-modal>
<sys-message-modal @register="registerMessageModal" @refresh="reloadCount" :systemMessageCount="systemMessageCount"></sys-message-modal>
<!-- 修改密码弹窗 -->
<ChangePasswordModal @register="changePwdModal"></ChangePasswordModal>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, ref, unref, reactive, onMounted, getCurrentInstance } from 'vue';
import { computed, defineComponent, ref, unref, reactive, onMounted, onUnmounted, getCurrentInstance } from 'vue';
import { Popover, Tabs, Badge } from 'ant-design-vue';
import { BellOutlined } from '@ant-design/icons-vue';
// import { tabListData } from './data';
@@ -25,7 +25,7 @@
import { useDesign } from '/@/hooks/web/useDesign';
import { useGlobSetting } from '/@/hooks/setting';
import { useUserStore } from '/@/store/modules/user';
import { connectWebSocket, onWebSocket, buildSystemWebSocketUrl } from '/@/hooks/web/useWebSocket';
import { connectWebSocket, onWebSocket, offWebSocket, buildSystemWebSocketUrl } from '/@/hooks/web/useWebSocket';
import { readAllMsg } from '/@/views/monitor/mynews/mynews.api';
import { useRouter } from 'vue-router';
@@ -33,6 +33,8 @@
import ChangePasswordModal from './ChangePasswordModal.vue'
import { ElectronEnum } from '/@/enums/jeecgEnum';
import { defHttp } from "@/utils/http/axios";
import { handleImChatSocket } from '/@/views/system/im/imCache';
import { useImUnread } from '/@/views/system/im/useImUnread';
export default defineComponent({
components: {
@@ -83,11 +85,20 @@
}
const popoverVisible = ref<boolean>(false);
const systemMessageCount = ref(0);
const { totalUnread: imUnreadCount, conversationUnreadCount: imConversationUnreadCount, refreshImUnread: refreshImUnreadCount } = useImUnread();
const messageCount = computed(() => (systemMessageCount.value || 0) + (imConversationUnreadCount.value || 0));
onMounted(() => {
initWebSocket();
initWebSocket();
refreshImUnreadCount(true);
loadData();
});
onUnmounted(() => {
offWebSocket(onWebSocketMessage);
});
const messageCount = ref<number>(0)
function mapAnnouncement(item) {
return {
...item,
@@ -111,7 +122,7 @@
let msgCount = await getUnreadMessageCount();
// 代码逻辑说明: 【QQYUN-12162】OA项目改造系统重消息拆分目前消息都在一起 需按分类进行拆分---
unReadNum.value = msgCount;
messageCount.value = msgCount.count?msgCount.count:0;
systemMessageCount.value = msgCount.count ? msgCount.count : 0;
// 代码逻辑说明: 【JHHB-13】桌面应用消息通知
if (glob.isElectronPlatform) {
window[ElectronEnum.ELECTRON_API].sendNotifyFlash(messageCount.value);
@@ -122,7 +133,21 @@
}
}
loadData();
function onWebSocketMessage(data) {
if (data.cmd === 'chat') {
handleImChatSocket(data);
refreshImUnreadCount(false);
return;
}
if (data.cmd === 'topic' || data.cmd === 'user') {
if (data.noticeType) {
noticeType.value = data.noticeType;
}
notification(data);
loadData();
window.setTimeout(() => loadData(), 800);
}
}
function onNoticeClick(record) {
try {
@@ -154,25 +179,9 @@
onWebSocket(onWebSocketMessage);
}
function onWebSocketMessage(data) {
if (data.cmd === 'topic' || data.cmd === 'user') {
// 代码逻辑说明: VUEN-1674【严重bug】系统通知为什么必须刷新右上角才提示
if(data.noticeType){
noticeType.value = data.noticeType;
}
//后台保存数据太慢 前端延迟刷新消息
setTimeout(()=>{
// 代码逻辑说明: 【JHHB-13】桌面应用消息通知
notification(data);
loadData();
}, 1000)
}
}
// 桌面应用通知
function notification(data) {
if (glob.isElectronPlatform && (data.noticeType || data.cmd == 'email')) {
// 流程、文件、日程、系统、会议
// flow、file、plan、system、meeting
let title = '';
let msgTxt = '';
let path = '';
@@ -199,6 +208,7 @@
window[ElectronEnum.ELECTRON_API].sendNotification(`有新的${title}消息`, msgTxt, path);
}
}
// 清空消息
function onEmptyNotify() {
popoverVisible.value = false;
@@ -208,6 +218,7 @@
try {
await editCementSend(id);
await loadData();
refreshImUnreadCount(true);
} catch (e) {
console.error(e);
}

View File

@@ -0,0 +1,442 @@
<template>
<div class="im-biz-record-message">
<div v-if="showNoPermission" class="im-biz-record-no-permission">暂无当前消息权限</div>
<template v-else>
<template v-if="isSingleItem">
<div class="im-biz-record-item">
<div class="im-biz-record-table-wrap">
<table class="im-biz-record-table im-biz-record-table--detail">
<tbody>
<tr v-for="field in resolveItemFields(singleItem)" :key="field.label">
<th>{{ field.label }}</th>
<td>{{ field.value }}</td>
</tr>
</tbody>
</table>
</div>
<a class="im-biz-record-link" @click.prevent="handleLinkClick(singleItem.linkPath)">
<Icon icon="ant-design:link-outlined" />
<span>查看并定位到此数据</span>
</a>
</div>
</template>
<!-- 多条列表表第一列为定位链接 -->
<template v-else>
<div class="im-biz-record-table-wrap im-biz-record-table-wrap--list">
<table class="im-biz-record-table im-biz-record-table--list">
<thead>
<tr>
<th class="im-biz-record-link-col">链接</th>
<th v-for="columnLabel in listColumnLabels" :key="columnLabel">{{ columnLabel }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in payload.items" :key="item.recordId || index">
<td class="im-biz-record-link-col">
<a class="im-biz-record-link" @click.prevent="handleLinkClick(item.linkPath)">
<Icon icon="ant-design:link-outlined" />
<span>定位</span>
</a>
</td>
<td v-for="columnLabel in listColumnLabels" :key="columnLabel">
{{ getFieldValue(item, columnLabel) }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div v-if="showPeerNoPermissionTip" class="im-biz-record-peer-tip">对方无此功能权限</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import type { ImBizRecordItem, ImBizRecordPayload } from './imBizRecordMessage';
import {
getImBizRecordFieldValueByLabel,
resolveImBizRecordItemFields,
resolveImBizRecordListColumnLabels,
} from './imBizRecordMessage';
import { navigateImBizRecordLink } from './imRecordLocate';
import { hasImBizRecordPagePermission } from './imBizRecordPermission';
defineOptions({ name: 'ImBizRecordMessageContent' });
const props = defineProps<{
payload: ImBizRecordPayload;
mine?: boolean;
receiverHasBizPagePermission?: boolean;
}>();
const isSingleItem = computed(() => props.payload.items.length === 1);
const singleItem = computed(() => props.payload.items[0]);
const listColumnLabels = computed(() => resolveImBizRecordListColumnLabels(props.payload.items));
const hasPagePermission = computed(() => hasImBizRecordPagePermission(props.payload.pagePath));
const showNoPermission = computed(() => !props.mine && !hasPagePermission.value);
const showPeerNoPermissionTip = computed(
() => !!props.mine && props.receiverHasBizPagePermission === false,
);
function resolveItemFields(item: ImBizRecordItem) {
return resolveImBizRecordItemFields(item);
}
function getFieldValue(item: ImBizRecordItem, label: string) {
return getImBizRecordFieldValueByLabel(item, label);
}
async function handleLinkClick(linkPath: string) {
if (!linkPath || showNoPermission.value) {
return;
}
await navigateImBizRecordLink(linkPath);
}
</script>
<style lang="less" scoped>
.im-biz-record-message {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 280px;
max-width: 420px;
}
.im-biz-record-no-permission {
padding: 12px 10px;
font-size: 13px;
line-height: 1.5;
color: #8c8c8c;
text-align: center;
}
.im-biz-record-peer-tip {
display: inline-flex;
align-items: center;
align-self: flex-start;
margin-top: 4px;
padding: 2px 8px;
border-radius: 10px;
background: #fff7e6;
border: 1px solid #ffd591;
font-size: 12px;
line-height: 1.5;
color: #d46b08;
}
.im-biz-record-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.im-biz-record-table-wrap {
overflow: hidden;
border: 1px solid #f0f0f0;
border-radius: 6px;
background: #fff;
&--list {
overflow-x: auto;
}
}
.im-biz-record-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
line-height: 1.5;
th,
td {
padding: 8px 10px;
border-bottom: 1px solid #f0f0f0;
vertical-align: top;
word-break: break-word;
}
tr:last-child {
th,
td {
border-bottom: none;
}
}
&--detail {
table-layout: fixed;
th {
width: 38%;
background: #fafafa;
color: #595959;
font-weight: 500;
text-align: left;
}
td {
color: #262626;
background: #fff;
}
}
&--list {
min-width: 100%;
table-layout: auto;
thead th {
background: #fafafa;
color: #595959;
font-weight: 500;
text-align: left;
white-space: nowrap;
}
tbody td {
color: #262626;
background: #fff;
}
}
}
.im-biz-record-link-col {
width: 72px;
min-width: 72px;
white-space: nowrap;
}
.im-biz-record-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #1677ff;
text-decoration: underline;
cursor: pointer;
&:hover {
color: #0958d9;
}
}
</style>

View File

@@ -50,7 +50,7 @@
<span class="conv-time">{{ formatTime(item.lastTime) }}</span>
</div>
<div class="conv-bottom">
<span class="conv-preview">{{ item.lastContent || '点击开始聊天' }}</span>
<span class="conv-preview">{{ formatConvPreview(item.lastContent) }}</span>
</div>
</div>
</div>
@@ -96,42 +96,91 @@
<a-avatar :size="32" :src="getAvatarUrl(msg.senderAvatar)">
{{ (msg.senderName || '?').slice(0, 1) }}
</a-avatar>
<div class="message-bubble">
<div
class="message-bubble"
:class="{
'message-bubble--image': isImImageMessage(msg.msgType),
'message-bubble--biz-record': isImBizRecordMessage(msg.msgType),
}"
>
<div class="message-name" v-if="!msg.mine">{{ msg.senderName }}</div>
<div class="message-content">{{ msg.content }}</div>
<img
v-if="isImImageMessage(msg.msgType)"
class="message-image"
:src="getAvatarUrl(msg.content)"
alt="图片消息"
@click="openImagePreview(msg.content)"
/>
<div v-else-if="isImTextMessage(msg.msgType)" class="message-content">{{ renderMessageText(msg.content) }}</div>
<ImBizRecordMessageContent
v-else-if="isImBizRecordMessage(msg.msgType) && getBizRecordPayload(msg.content)"
:payload="getBizRecordPayload(msg.content)!"
:mine="msg.mine"
:receiver-has-biz-page-permission="msg.receiverHasBizPagePermission"
/>
<div v-else 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>
<!--update-begin---author:xsl ---date:20260528 forIM聊天-OA弹窗输入框上方展示背后功能页名称------------->
<div
v-if="embeddedPageContextTitle"
:class="['im-page-context-bubble', { 'is-clickable': embeddedPageContextClickable }]"
:title="embeddedPageContextBubbleTitle"
@click="handlePageContextBubbleClick"
>
<Icon icon="ant-design:environment-outlined" class="im-page-context-icon" />
<span class="im-page-context-text">{{ embeddedPageContextTitle }}</span>
<Icon v-if="embeddedPageContextClickable" icon="ant-design:select-outlined" class="im-page-context-action" />
</div>
<!--update-end---author:xsl ---date:20260528 forIM聊天-OA弹窗输入框上方展示背后功能页名称------------->
<ImChatInput
v-model="draft"
:disabled="!activeConversationId"
:sending="sending"
@send="handleSend"
@image-uploaded="sendImageMessage"
/>
</div>
</template>
<a-empty v-else class="empty-chat" description="请从左侧选择本部门同事开始聊天" />
</main>
</div>
<ImChatSettingsModal @register="registerSettingsModal" @saved="onChatSettingsSaved" />
<ImPageListPickModal @register="registerListPickModal" @confirm="handleListRowsSend" />
<a-modal :open="previewVisible" :footer="null" width="720px" @cancel="closeImagePreview">
<img alt="图片预览" style="width: 100%" :src="previewImageUrl" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { computed, nextTick, onActivated, onDeactivated, 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 {
formatImMessagePreview,
IM_MSG_TYPE_BIZ_RECORD,
IM_MSG_TYPE_IMAGE,
IM_MSG_TYPE_TEXT,
isImImageMessage,
isImTextMessage,
isImBizRecordMessage,
resolveImEmojiText,
} from './imMessageUtil';
import ImChatInput from './ImChatInput.vue';
import ImChatSettingsModal from './ImChatSettingsModal.vue';
import ImPageListPickModal from './ImPageListPickModal.vue';
import ImBizRecordMessageContent from './ImBizRecordMessageContent.vue';
import { useModal } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { buildImBizRecordPayload, parseImBizRecordPayload, serializeImBizRecordPayload } from './imBizRecordMessage';
import { syncImUnreadFromMembers } from './useImUnread';
import {
type ImMemberItem,
@@ -146,7 +195,18 @@
patchCachedMember,
prefetchImChatData,
setImActiveConversationId,
setImActiveTargetUserId,
clearImActiveSession,
isImChatWindowOpen,
onImMessagesUpdated,
onImChatWindowOpenChange,
} from './imCache';
import {
setImChatPageActive,
isImChatPageActive,
onImOpenTargetRequest,
useImPageContext,
} from './imSession';
defineOptions({ name: 'ImChat' });
@@ -165,7 +225,71 @@
interface MessageItem extends ImMessageItem {}
const userStore = useUserStore();
const pageTabActive = ref(true);
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】弹窗输入框上方展示背后功能页名称-----------
const imPageContext = useImPageContext();
const { createMessage } = useMessage();
const embeddedPageContextTitle = computed(() => {
if (!props.embedded) {
return '';
}
return imPageContext.value?.title || '';
});
const embeddedPageContextClickable = computed(() => {
if (!props.embedded) {
return false;
}
return !!imPageContext.value?.listSnapshot?.records?.length;
});
const embeddedPageContextBubbleTitle = computed(() => {
if (embeddedPageContextClickable.value) {
return `${embeddedPageContextTitle.value}(点击选择明细发送)`;
}
return embeddedPageContextTitle.value;
});
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】弹窗输入框上方展示背后功能页名称-----------
let unsubscribeMessagesUpdated: (() => void) | null = null;
let unsubscribeChatWindowOpen: (() => void) | null = null;
let unsubscribeOpenTarget: (() => void) | null = null;
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】弹窗与全页互斥统一会话激活判断-----------
/** 当前实例是否处于前台可交互状态(全页与弹窗同一套逻辑,同一时刻仅一个实例激活) */
function isThisInstanceActive() {
if (props.embedded) {
return isImChatWindowOpen() && !isImChatPageActive();
}
return isImChatPageActive() && pageTabActive.value;
}
/** 从共享缓存同步当前会话消息(弹窗与全页 IM 双实例同步) */
function syncMessagesFromCache() {
if (!activeConversationId.value) {
return;
}
const cached = getCachedMessages(activeConversationId.value);
if (!cached?.records.length) {
return;
}
messageList.value = filterDefaultRangeMessages(cached.records);
updateHasMore(messageList.value);
nextTick(() => scrollToBottom());
}
/** 页签重新激活或弹窗关闭后,恢复聊天记录 */
function restoreConversationView() {
if (!activeConversationId.value) {
return;
}
syncMessagesFromCache();
if (hasNewerMessagesThanCache(activeConversationId.value)) {
loadMessages(true, { forceRefresh: true });
}
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】弹窗与全页互斥统一会话激活判断-----------
const [registerSettingsModal, { openModal: openSettingsModal }] = useModal();
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】点击功能名气泡选择列表明细发送-----------
const [registerListPickModal, { openModal: openListPickModal }] = useModal();
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】点击功能名气泡选择列表明细发送-----------
const defaultHistoryDays = ref(getImDefaultHistoryDays());
const currentUserId = computed(() => userStore.getUserInfo?.id || '');
const deptLabel = computed(() => {
@@ -176,6 +300,23 @@
const memberLoading = ref(false);
const msgLoading = ref(false);
const sending = ref(false);
const previewVisible = ref(false);
const previewImageUrl = ref('');
function renderMessageText(content?: string) {
return resolveImEmojiText(content);
}
function getBizRecordPayload(content?: string) {
return parseImBizRecordPayload(content);
}
function formatConvPreview(content?: string) {
if (!content) {
return '点击开始聊天';
}
return formatImMessagePreview(content);
}
const memberKeyword = ref('');
const draft = ref('');
const deptMembers = ref<DeptMemberItem[]>([]);
@@ -261,6 +402,19 @@
return avatar ? getFileAccessHttpUrl(avatar) : '';
}
function openImagePreview(imagePath?: string) {
if (!imagePath) {
return;
}
previewImageUrl.value = getAvatarUrl(imagePath);
previewVisible.value = true;
}
function closeImagePreview() {
previewVisible.value = false;
previewImageUrl.value = '';
}
function formatTime(value?: string) {
if (!value) {
return '';
@@ -280,6 +434,27 @@
return records.filter((item) => isWithinDefaultHistoryRange(item.createTime));
}
/** 会话摘要时间晚于本地缓存最新消息时,需要拉取服务端 */
function hasNewerMessagesThanCache(conversationId: string) {
const cached = getCachedMessages(conversationId);
const lastMemberTime = activeMember.value?.lastTime;
if (!lastMemberTime) {
return false;
}
if (!cached?.records.length) {
return true;
}
const latestCachedTime = cached.records[cached.records.length - 1]?.createTime;
if (!latestCachedTime) {
return true;
}
return dayjs(lastMemberTime).isAfter(dayjs(latestCachedTime));
}
function shouldFetchLatestMessages(conversationId: string, forceRefresh = false) {
return forceRefresh || isMessagesCacheStale(conversationId) || hasNewerMessagesThanCache(conversationId);
}
function handleSettingsMenuClick({ key }: { key: string }) {
if (key === 'chatSettings') {
openSettingsModal(true, {});
@@ -322,6 +497,7 @@
}
syncImUnreadFromMembers(deptMembers.value);
syncActiveMember();
applyActiveSessionReadState();
} finally {
if (!silent && !usedCache) {
memberLoading.value = false;
@@ -373,13 +549,24 @@
}
const found = deptMembers.value.find((item) => item.id === activeTargetUserId.value);
if (found) {
activeMember.value = found;
const normalized = { ...found, unreadCount: 0 };
activeMember.value = normalized;
if (found.conversationId) {
activeConversationId.value = found.conversationId;
setImActiveConversationId(found.conversationId);
}
}
}
/** 当前正在查看的会话:本地与角标均视为已读 */
function applyActiveSessionReadState() {
if (!activeTargetUserId.value || !activeConversationId.value) {
return;
}
patchDeptMember(activeTargetUserId.value, { unreadCount: 0 }, { moveToTop: false });
markRead(activeConversationId.value).catch(() => {});
}
/** 是否还有更早的消息可加载 */
function updateHasMore(latestBatch: MessageItem[]) {
if (latestBatch.length >= pageSize) {
@@ -399,7 +586,7 @@
hasMore.value = dayjs(lastTime).isBefore(dayjs(oldestLoaded));
}
async function loadMessages(reset = true) {
async function loadMessages(reset = true, options?: { forceRefresh?: boolean }) {
if (!activeConversationId.value) {
messageList.value = [];
return;
@@ -407,6 +594,8 @@
const conversationId = activeConversationId.value;
const requestSeq = ++loadMessagesSeq;
let displayedFromCache = false;
const forceRefresh = !!options?.forceRefresh;
const needFetch = shouldFetchLatestMessages(conversationId, forceRefresh);
if (reset) {
const cached = getCachedMessages(conversationId);
@@ -420,7 +609,7 @@
if (activeTargetUserId.value) {
patchDeptMember(activeTargetUserId.value, { unreadCount: 0 }, { moveToTop: false });
}
if (!isMessagesCacheStale(conversationId)) {
if (!needFetch) {
return;
}
} else {
@@ -504,17 +693,24 @@
}
}
async function selectMember(item: DeptMemberItem) {
async function selectMember(item: DeptMemberItem, options?: { forceRefreshMessages?: boolean }) {
const forceRefreshMessages = !!options?.forceRefreshMessages;
if (activeTargetUserId.value === item.id && activeConversationId.value) {
activeMember.value = { ...item, unreadCount: 0 };
applyActiveSessionReadState();
if (forceRefreshMessages || hasNewerMessagesThanCache(activeConversationId.value)) {
await loadMessages(true, { forceRefresh: true });
}
return;
}
activeTargetUserId.value = item.id;
activeMember.value = item;
setImActiveTargetUserId(item.id);
activeMember.value = { ...item, unreadCount: 0 };
if (item.conversationId) {
activeConversationId.value = item.conversationId;
setImActiveConversationId(item.conversationId);
await loadMessages(true);
await loadMessages(true, { forceRefresh: forceRefreshMessages });
return;
}
@@ -534,7 +730,7 @@
lastTime: conv.lastTime,
unreadCount: 0,
}, { moveToTop: false });
await loadMessages(true);
await loadMessages(true, { forceRefresh: forceRefreshMessages });
}
function scrollToBottom() {
@@ -544,22 +740,24 @@
}
}
async function handleSend() {
const content = draft.value.trim();
if (!content || !activeConversationId.value) {
async function sendImageMessage(imagePath: string) {
if (!activeConversationId.value) {
return;
}
sending.value = true;
try {
const msg = await sendMessage({ conversationId: activeConversationId.value, content, msgType: 'text' });
const msg = await sendMessage({
conversationId: activeConversationId.value,
content: imagePath,
msgType: IM_MSG_TYPE_IMAGE,
});
messageList.value.push(msg);
appendCachedMessage(activeConversationId.value, msg);
draft.value = '';
await nextTick();
scrollToBottom();
patchDeptMember(activeTargetUserId.value, {
conversationId: activeConversationId.value,
lastContent: msg.content,
lastContent: formatImMessagePreview(msg.content, msg.msgType),
lastTime: msg.createTime,
unreadCount: 0,
});
@@ -568,21 +766,105 @@
}
}
function handlePressEnter(e: KeyboardEvent) {
if (e.shiftKey) {
async function handleSend() {
const content = draft.value.trim();
if (!content || !activeConversationId.value) {
return;
}
e.preventDefault();
handleSend();
await sendTextMessage(content);
draft.value = '';
}
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】点击功能名气泡选择列表明细发送-----------
function handlePageContextBubbleClick() {
if (!embeddedPageContextClickable.value) {
return;
}
if (!activeConversationId.value) {
createMessage.warning('请先选择聊天对象');
return;
}
openListPickModal(true, { snapshot: imPageContext.value?.listSnapshot || null });
}
async function handleListRowsSend(rows: Recordable[]) {
if (!rows.length || !activeConversationId.value) {
return;
}
const snapshot = imPageContext.value?.listSnapshot;
if (!snapshot) {
return;
}
const payload = buildImBizRecordPayload({
pageTitle: imPageContext.value?.title || snapshot.title,
pagePath: imPageContext.value?.path || snapshot.pagePath,
rowKey: snapshot.rowKey,
columns: snapshot.columns,
sourceColumns: snapshot.sourceColumns,
rows,
});
await sendBizRecordMessage(payload);
}
async function sendBizRecordMessage(payload: ReturnType<typeof buildImBizRecordPayload>) {
if (!activeConversationId.value) {
return;
}
sending.value = true;
try {
const content = serializeImBizRecordPayload(payload);
const msg = await sendMessage({
conversationId: activeConversationId.value,
content,
msgType: IM_MSG_TYPE_BIZ_RECORD,
});
messageList.value.push(msg);
appendCachedMessage(activeConversationId.value, msg);
await nextTick();
scrollToBottom();
patchDeptMember(activeTargetUserId.value, {
conversationId: activeConversationId.value,
lastContent: formatImMessagePreview(msg.content, msg.msgType),
lastTime: msg.createTime,
unreadCount: 0,
});
} finally {
sending.value = false;
}
}
async function sendTextMessage(content: string) {
if (!content || !activeConversationId.value) {
return;
}
sending.value = true;
try {
const msg = await sendMessage({ conversationId: activeConversationId.value, content, msgType: IM_MSG_TYPE_TEXT });
messageList.value.push(msg);
appendCachedMessage(activeConversationId.value, msg);
await nextTick();
scrollToBottom();
patchDeptMember(activeTargetUserId.value, {
conversationId: activeConversationId.value,
lastContent: formatImMessagePreview(msg.content, msg.msgType),
lastTime: msg.createTime,
unreadCount: 0,
});
} finally {
sending.value = false;
}
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】点击功能名气泡选择列表明细发送-----------
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;
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】仅前台实例处理当前会话 WS 消息-----------
const isActiveConversation = isThisInstanceActive() && !!conversationId && conversationId === activeConversationId.value;
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】仅前台实例处理当前会话 WS 消息-----------
if (isActiveConversation) {
const exists = messageList.value.some((item) => item.id === data.messageId);
@@ -605,7 +887,7 @@
if (activeTargetUserId.value) {
patchDeptMember(activeTargetUserId.value, {
conversationId,
lastContent: data.content,
lastContent: formatImMessagePreview(data.content, data.msgType),
lastTime: data.createTime,
unreadCount: 0,
});
@@ -618,7 +900,7 @@
senderId,
{
conversationId,
lastContent: data.content,
lastContent: formatImMessagePreview(data.content, data.msgType),
lastTime: data.createTime,
},
{ unreadIncrement: 1 },
@@ -631,11 +913,51 @@
onWebSocket(onChatSocket);
loadDeptMembers();
prefetchImChatData();
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】弹窗与全页双实例共享缓存同步消息-----------
unsubscribeMessagesUpdated = onImMessagesUpdated((conversationId) => {
if (conversationId && conversationId === activeConversationId.value) {
syncMessagesFromCache();
}
});
if (!props.embedded) {
setImChatPageActive(true);
unsubscribeChatWindowOpen = onImChatWindowOpenChange((open) => {
if (!open) {
restoreConversationView();
applyActiveSessionReadState();
}
});
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】通知/消息列表统一在全页 IM 打开会话-----------
unsubscribeOpenTarget = onImOpenTargetRequest((targetUserId) => openTargetChat(targetUserId));
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】通知/消息列表统一在全页 IM 打开会话-----------
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】弹窗与全页双实例共享缓存同步消息-----------
});
if (!props.embedded) {
onActivated(() => {
pageTabActive.value = true;
setImChatPageActive(true);
restoreConversationView();
applyActiveSessionReadState();
});
onDeactivated(() => {
pageTabActive.value = false;
setImChatPageActive(false);
});
}
/** 弹窗再次打开时,若消息被并发请求清空则从缓存恢复 */
function restoreSessionIfNeeded() {
if (!activeConversationId.value || messageList.value.length > 0) {
if (!activeConversationId.value) {
return;
}
if (hasNewerMessagesThanCache(activeConversationId.value)) {
loadMessages(true, { forceRefresh: true });
return;
}
if (messageList.value.length > 0) {
applyActiveSessionReadState();
return;
}
const cached = getCachedMessages(activeConversationId.value);
@@ -643,16 +965,61 @@
messageList.value = filterDefaultRangeMessages(cached.records);
updateHasMore(messageList.value);
nextTick(() => scrollToBottom());
applyActiveSessionReadState();
return;
}
loadMessages(true);
}
defineExpose({ restoreSessionIfNeeded });
/** 从外部打开指定同事的会话(消息提醒等场景) */
async function openTargetChat(userId: string) {
if (!userId) {
return;
}
if (!deptMembers.value.length) {
await loadDeptMembers(false, true);
}
let found = deptMembers.value.find((item) => item.id === userId);
if (!found) {
await loadDeptMembers(false, true);
found = deptMembers.value.find((item) => item.id === userId);
}
if (found) {
await selectMember(found, { forceRefreshMessages: true });
}
}
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】弹窗关闭仅暂停会话标记-----------
/** 弹窗关闭时仅清除全局「正在查看」标记,保留本地消息列表 */
function pauseActiveSession() {
clearImActiveSession();
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】弹窗关闭仅暂停会话标记-----------
/** 关闭聊天窗口时重置本地会话,避免后台继续吞掉新消息未读 */
function clearActiveSession() {
activeTargetUserId.value = '';
activeConversationId.value = '';
activeMember.value = null;
messageList.value = [];
draft.value = '';
clearImActiveSession();
syncImUnreadFromMembers(deptMembers.value);
}
defineExpose({ restoreSessionIfNeeded, openTargetChat, clearActiveSession, pauseActiveSession });
onUnmounted(() => {
offWebSocket(onChatSocket);
setImActiveConversationId('');
unsubscribeMessagesUpdated?.();
unsubscribeChatWindowOpen?.();
unsubscribeOpenTarget?.();
if (!props.embedded) {
setImChatPageActive(false);
}
if (isThisInstanceActive()) {
clearImActiveSession();
}
stopResize();
});
</script>
@@ -978,13 +1345,101 @@
.message-input {
border-top: 1px solid #f0f0f0;
padding: 12px 16px 16px;
padding: 10px 12px 12px;
background: #fafafa;
}
.input-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】弹窗输入框上方展示背后功能页名称-----------
.im-page-context-bubble {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
margin-bottom: 8px;
padding: 4px 10px;
border-radius: 999px;
background: #e6f4ff;
border: 1px solid #bae0ff;
color: #0958d9;
font-size: 12px;
line-height: 1.4;
box-shadow: 0 1px 2px rgba(22, 119, 255, 0.08);
}
.im-page-context-icon {
flex-shrink: 0;
font-size: 13px;
}
.im-page-context-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.im-page-context-action {
flex-shrink: 0;
font-size: 12px;
opacity: 0.85;
}
.im-page-context-bubble.is-clickable {
cursor: pointer;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
&:hover {
background: #d6ebff;
border-color: #69b1ff;
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.12);
}
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】弹窗输入框上方展示背后功能页名称-----------
.message-content {
word-break: break-word;
white-space: pre-wrap;
line-height: 1.6;
}
.message-bubble--image {
background: transparent !important;
box-shadow: none;
padding: 0;
.message-time {
margin-top: 4px;
color: #999 !important;
}
}
.message-bubble--biz-record {
background: #fff !important;
color: #262626 !important;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
padding: 8px 10px 6px;
.message-time {
color: #999 !important;
text-align: right;
}
}
.message-row.mine .message-bubble--biz-record {
background: #fff !important;
color: #262626 !important;
}
.message-image {
max-width: 240px;
max-height: 240px;
border-radius: 8px;
cursor: pointer;
display: block;
object-fit: cover;
}
.message-row.mine .message-bubble--image .message-time {
text-align: right;
}
.empty-chat {

View File

@@ -0,0 +1,413 @@
<template>
<div class="im-chat-input-wrap">
<div class="im-chat-input" :class="{ focused: inputFocused, disabled: disabled }">
<a-textarea
ref="textareaRef"
:value="modelValue"
:auto-size="{ minRows: 3, maxRows: 8 }"
placeholder="请输入消息"
:bordered="false"
:disabled="disabled"
class="im-chat-input-textarea"
@update:value="handleInput"
@focus="inputFocused = true"
@blur="handleBlur"
@keydown="handleKeydown"
/>
<div class="im-chat-input-toolbar">
<div class="toolbar-left">
<a-popover
v-model:open="emojiVisible"
trigger="click"
placement="topLeft"
overlay-class-name="im-emoji-popover"
:overlay-style="{ padding: 0 }"
:get-popup-container="getPopupContainer"
>
<template #content>
<Picker
:picker-styles="pickerStyles"
:i18n="emojiI18n"
:data="emojiIndex"
emoji="grinning"
:native="true"
:show-preview="false"
:infinite-scroll="false"
:show-search="true"
:show-skin-tones="false"
set="native"
@select="handleSelectEmoji"
/>
</template>
<button type="button" class="toolbar-btn" title="表情" :disabled="disabled">
<Icon icon="ant-design:smile-outlined" />
</button>
</a-popover>
<a-upload
:show-upload-list="false"
:action="uploadUrl"
:headers="uploadHeaders"
:data="{ biz: 'im/chat' }"
:accept="IM_IMAGE_ACCEPT"
:before-upload="beforeImageUpload"
:disabled="disabled || uploadingImage"
@change="handleImageUploadChange"
>
<button type="button" class="toolbar-btn" title="发送图片" :disabled="disabled || uploadingImage">
<Icon v-if="!uploadingImage" icon="ant-design:picture-outlined" />
<LoadingOutlined v-else spin />
</button>
</a-upload>
</div>
<div class="toolbar-right">
<div class="send-btn-group">
<a-button type="primary" class="send-btn" :disabled="!canSend" :loading="sending" @click="emitSend">
发送(S)
</a-button>
<a-dropdown :trigger="['click']" placement="topRight">
<a-button type="primary" class="send-btn-arrow" :disabled="sending">
<Icon icon="ant-design:down-outlined" />
</a-button>
<template #overlay>
<a-menu :selected-keys="[sendMode]" @click="handleSendModeChange">
<a-menu-item key="enter"> Enter 发送</a-menu-item>
<a-menu-item key="ctrlEnter"> Ctrl+Enter 发送</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</div>
</div>
<div class="im-chat-input-hint">{{ sendModeHint }}</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import type { UploadChangeParam, UploadProps } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { LoadingOutlined } from '@ant-design/icons-vue';
import 'emoji-mart-vue-fast/css/emoji-mart.css';
import { getGloablEmojiIndex } from '/@/components/jeecg/comment/useComment';
import { uploadUrl } from '/@/api/common/api';
import { getHeaders } from '/@/utils/common/compUtils';
import { getEmojiInsertText, IM_IMAGE_ACCEPT, IM_IMAGE_MAX_SIZE } from './imMessageUtil';
type SendMode = 'enter' | 'ctrlEnter';
const SEND_MODE_KEY = 'im-chat-send-mode';
defineOptions({ name: 'ImChatInput' });
const props = withDefaults(
defineProps<{
modelValue: string;
disabled?: boolean;
sending?: boolean;
}>(),
{
disabled: false,
sending: false,
},
);
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'send'): void;
(e: 'image-uploaded', imagePath: string): void;
}>();
const emojiI18n = {
categories: {
recent: '最近使用',
smileys: '全部表情',
people: '人物',
nature: '自然',
foods: '食物',
activity: '活动',
places: '地点',
objects: '物品',
symbols: '符号',
flags: '旗帜',
},
search: '搜索表情',
notfound: '未找到表情',
};
const pickerStyles = { width: '360px', border: 'none' };
const emojiIndex = getGloablEmojiIndex();
const uploadHeaders = getHeaders();
const textareaRef = ref<{ resizableTextArea?: { textArea: HTMLTextAreaElement } }>();
const inputFocused = ref(false);
const emojiVisible = ref(false);
const uploadingImage = ref(false);
const sendMode = ref<SendMode>(loadSendMode());
const canSend = computed(() => !props.disabled && !!props.modelValue.trim() && !props.sending);
const sendModeHint = computed(() =>
sendMode.value === 'enter' ? 'Enter / Alt+S 发送Ctrl+Enter 换行' : 'Ctrl+Enter / Alt+S 发送Enter 换行',
);
function loadSendMode(): SendMode {
const saved = localStorage.getItem(SEND_MODE_KEY);
return saved === 'ctrlEnter' ? 'ctrlEnter' : 'enter';
}
function handleSendModeChange({ key }: { key: string }) {
sendMode.value = key as SendMode;
localStorage.setItem(SEND_MODE_KEY, key);
}
function getPopupContainer(triggerNode: HTMLElement) {
return triggerNode.parentElement || document.body;
}
function getTextareaEl() {
return textareaRef.value?.resizableTextArea?.textArea;
}
function handleInput(value: string) {
emit('update:modelValue', value);
}
function handleBlur() {
window.setTimeout(() => {
inputFocused.value = false;
}, 120);
}
function insertAtCursor(text: string) {
const el = getTextareaEl();
const current = props.modelValue || '';
if (!el) {
emit('update:modelValue', current + text);
return;
}
const start = el.selectionStart ?? current.length;
const end = el.selectionEnd ?? start;
const nextValue = current.slice(0, start) + text + current.slice(end);
emit('update:modelValue', nextValue);
const cursor = start + text.length;
window.requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(cursor, cursor);
});
}
function handleSelectEmoji(item: Record<string, any>) {
const text = getEmojiInsertText(item);
if (!text) {
return;
}
insertAtCursor(text);
emojiVisible.value = false;
inputFocused.value = true;
window.requestAnimationFrame(() => getTextareaEl()?.focus());
}
function emitSend() {
if (!canSend.value) {
return;
}
emit('send');
}
function handleKeydown(e: KeyboardEvent) {
if (e.altKey && !e.ctrlKey && !e.shiftKey && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
emitSend();
return;
}
if (e.key !== 'Enter') {
return;
}
const enterSend = sendMode.value === 'enter';
const shouldSend = enterSend ? !e.ctrlKey && !e.shiftKey : e.ctrlKey && !e.shiftKey;
const shouldNewLine = enterSend ? e.ctrlKey || e.shiftKey : !e.ctrlKey || e.shiftKey;
if (shouldSend) {
e.preventDefault();
emitSend();
return;
}
if (shouldNewLine && e.ctrlKey && !e.shiftKey) {
e.preventDefault();
insertAtCursor('\n');
}
}
const beforeImageUpload: UploadProps['beforeUpload'] = (file) => {
if (!file.type?.startsWith('image/')) {
message.warning('仅支持发送图片文件');
return false;
}
if (file.size > IM_IMAGE_MAX_SIZE) {
message.warning('图片大小不能超过10MB');
return false;
}
if (props.disabled) {
message.warning('请先选择聊天对象');
return false;
}
uploadingImage.value = true;
return true;
};
function handleImageUploadChange(info: UploadChangeParam) {
const { file } = info;
if (file.status === 'uploading') {
return;
}
uploadingImage.value = false;
if (file.status === 'error') {
message.error(`${file.name} 上传失败`);
return;
}
if (file.status !== 'done') {
return;
}
const response = file.response as { success?: boolean; message?: string } | undefined;
if (!response?.success) {
message.warning(response?.message || '图片上传失败');
return;
}
const imagePath = response.message;
if (imagePath) {
emit('image-uploaded', imagePath);
}
}
</script>
<style lang="less" scoped>
.im-chat-input-wrap {
width: 100%;
}
.im-chat-input {
border: 1px solid #e8e8e8;
border-radius: 12px;
background: #fff;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
&.focused {
border-color: #91caff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.08);
}
&.disabled {
background: #fafafa;
opacity: 0.85;
}
}
.im-chat-input-textarea {
padding: 12px 14px 4px !important;
font-size: 14px;
line-height: 1.6;
resize: none;
background: transparent !important;
box-shadow: none !important;
:deep(textarea) {
padding: 0 !important;
}
}
.im-chat-input-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 10px 8px;
gap: 8px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 2px;
}
.toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: #666;
font-size: 18px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
&:hover:not(:disabled) {
background: #f5f5f5;
color: #1677ff;
}
&:disabled {
cursor: not-allowed;
opacity: 0.45;
}
}
.toolbar-right {
display: flex;
align-items: center;
}
.send-btn-group {
display: inline-flex;
align-items: stretch;
border-radius: 8px;
overflow: hidden;
.send-btn {
min-width: 80px;
border-radius: 8px 0 0 8px;
font-weight: 500;
border-right: 1px solid rgba(255, 255, 255, 0.25);
}
.send-btn-arrow {
width: 32px;
padding: 0;
border-radius: 0 8px 8px 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
}
.im-chat-input-hint {
margin-top: 6px;
padding: 0 4px;
text-align: right;
font-size: 12px;
color: #bfbfbf;
line-height: 1.4;
user-select: none;
}
</style>
<style lang="less">
.im-emoji-popover {
.ant-popover-inner-content {
padding: 0;
}
.emoji-mart-bar {
border-bottom: 1px solid #f0f0f0;
}
.emoji-mart-emoji span {
line-height: 1;
}
}
</style>

View File

@@ -17,29 +17,77 @@
</template>
<script lang="ts" setup>
import { ref, nextTick } from 'vue';
import { ref, nextTick, onMounted, onUnmounted } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import ImChat from './ImChat.vue';
import { setImChatWindowOpen } from './imCache';
import { canOpenImChatModal, onImChatModalCloseRequest, openImChat, setImPageContext } from './imSession';
import { refreshImUnread } from './useImUnread';
defineOptions({ name: 'ImChatModal' });
const imChatRef = ref<InstanceType<typeof ImChat>>();
const pendingTargetUserId = ref('');
function restoreChatSession() {
nextTick(() => {
nextTick(async () => {
const targetUserId = pendingTargetUserId.value;
if (targetUserId) {
pendingTargetUserId.value = '';
await imChatRef.value?.openTargetChat?.(targetUserId);
return;
}
imChatRef.value?.restoreSessionIfNeeded?.();
});
}
const [registerModal] = useModalInner(() => {
const [registerModal, { closeModal }] = useModalInner((data?: { targetUserId?: string }) => {
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】全页 IM 已打开时拒绝弹窗,改由全页承接-----------
if (!canOpenImChatModal()) {
closeModal();
openImChat({ targetUserId: data?.targetUserId });
return;
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】全页 IM 已打开时拒绝弹窗,改由全页承接-----------
setImChatWindowOpen(true);
pendingTargetUserId.value = data?.targetUserId || '';
restoreChatSession();
});
function handleOpenChange(open: boolean) {
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】全页 IM 已打开时不展示弹窗-----------
if (open && !canOpenImChatModal()) {
closeModal();
return;
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】全页 IM 已打开时不展示弹窗-----------
setImChatWindowOpen(open);
if (open) {
restoreChatSession();
} else {
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】弹窗关闭不清空消息全页 IM 从缓存恢复-----------
imChatRef.value?.pauseActiveSession?.();
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】弹窗关闭不清空消息全页 IM 从缓存恢复-----------
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】弹窗关闭清除功能页快照-----------
setImPageContext(null);
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】弹窗关闭清除功能页快照-----------
refreshImUnread(true);
}
}
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】业务明细消息链接跳转时关闭 IM 弹窗-----------
let unsubscribeCloseModal: (() => void) | null = null;
onMounted(() => {
unsubscribeCloseModal = onImChatModalCloseRequest(() => {
closeModal();
});
});
onUnmounted(() => {
unsubscribeCloseModal?.();
});
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】业务明细消息链接跳转时关闭 IM 弹窗-----------
</script>
<style lang="less">

View File

@@ -0,0 +1,92 @@
<template>
<BasicModal
v-bind="$attrs"
:title="modalTitle"
:width="980"
:min-height="420"
ok-text="发送选中"
:ok-button-props="okButtonProps"
@register="registerModal"
@ok="handleConfirm"
>
<BasicTable @register="registerTable" :rowSelection="rowSelection" />
</BasicModal>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicColumn, BasicTable } from '/@/components/Table';
import { useListTable } from '/@/hooks/system/useListPage';
import type { ImPageListSnapshot } from './imPageListUtil';
import { getImListCellDisplayValue } from './imPageListUtil';
defineOptions({ name: 'ImPageListPickModal' });
const emit = defineEmits<{
(e: 'confirm', rows: Recordable[]): void;
}>();
const snapshotRef = ref<ImPageListSnapshot | null>(null);
const modalTitle = computed(() => {
const title = snapshotRef.value?.title;
return title ? `选择${title}明细` : '选择明细数据';
});
const [registerTable, { setTableData, setColumns, setProps }, { rowSelection, selectedRows, selectedRowKeys }] = useListTable({
rowKey: 'id',
columns: [],
dataSource: [],
useSearchForm: false,
showTableSetting: false,
showActionColumn: false,
canResize: true,
bordered: true,
pagination: {
pageSize: 10,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
},
});
const okButtonProps = computed(() => ({
disabled: !selectedRows.value.length,
}));
const [registerModal, { closeModal }] = useModalInner((data?: { snapshot?: ImPageListSnapshot | null }) => {
snapshotRef.value = data?.snapshot || null;
const snapshot = snapshotRef.value;
selectedRowKeys.value = [];
selectedRows.value = [];
if (!snapshot) {
setTableData([]);
setColumns([]);
return;
}
setProps({
rowKey: snapshot.rowKey,
});
setColumns(buildTableColumns(snapshot));
setTableData(snapshot.records);
});
function buildTableColumns(snapshot: ImPageListSnapshot): BasicColumn[] {
return snapshot.columns.map((col) => ({
title: col.title,
dataIndex: col.dataIndex,
width: col.width || 120,
align: 'center',
ellipsis: true,
customRender: ({ record }) => getImListCellDisplayValue(record, col.dataIndex) || '-',
}));
}
function handleConfirm() {
if (!selectedRows.value.length) {
return;
}
emit('confirm', selectedRows.value.slice());
closeModal();
}
</script>

View File

@@ -0,0 +1,156 @@
import {
buildImBizRecordFields,
getImListCellDisplayValue,
type ImBizRecordField,
type ImPageListColumn,
} from './imPageListUtil';
export const IM_MSG_TYPE_BIZ_RECORD = 'biz_record';
export const IM_BIZ_RECORD_VERSION = 2;
export const IM_RECORD_QUERY_KEY = 'imRecordId';
export interface ImBizRecordItem {
recordId: string;
/** v1 兼容:纯文本正文 */
body?: string;
/** v2表格字段 */
fields?: ImBizRecordField[];
linkPath: string;
}
export interface ImBizRecordPayload {
v: number;
pageTitle: string;
pagePath: string;
rowKey: string;
items: ImBizRecordItem[];
}
/** 构建带跳转链接的业务明细消息体 */
export function buildImBizRecordPayload(options: {
pageTitle: string;
pagePath: string;
rowKey: string;
columns: ImPageListColumn[];
sourceColumns?: import('/@/components/Table').BasicColumn[];
rows: Recordable[];
}): ImBizRecordPayload {
const pagePath = normalizeImPagePath(options.pagePath);
const sourceColumns = options.sourceColumns || [];
const items = options.rows.map((row, index) => {
const recordId = String(row[options.rowKey] ?? '');
const fields = buildImBizRecordFields(row, options.columns, sourceColumns);
const bodyLines: string[] = [];
if (options.rows.length > 1) {
bodyLines.push(`--- 第${index + 1}条 ---`);
}
fields.forEach((field) => {
bodyLines.push(`${field.label}: ${field.value}`);
});
return {
recordId,
fields,
body: bodyLines.join('\n'),
linkPath: buildImRecordLinkPath(pagePath, recordId),
};
});
return {
v: IM_BIZ_RECORD_VERSION,
pageTitle: options.pageTitle,
pagePath,
rowKey: options.rowKey,
items,
};
}
export function serializeImBizRecordPayload(payload: ImBizRecordPayload): string {
return JSON.stringify(payload);
}
export function parseImBizRecordPayload(content?: string): ImBizRecordPayload | null {
if (!content) {
return null;
}
try {
const data = JSON.parse(content);
if ((data?.v === 1 || data?.v === 2) && Array.isArray(data.items)) {
return data as ImBizRecordPayload;
}
} catch {
return null;
}
return null;
}
/** 解析展示字段(兼容 v1 纯文本消息) */
export function resolveImBizRecordItemFields(item: ImBizRecordItem): ImBizRecordField[] {
if (item.fields?.length) {
return item.fields;
}
if (!item.body) {
return [];
}
return item.body
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('---'))
.map((line) => {
const splitIndex = line.indexOf(':');
if (splitIndex <= 0) {
return null;
}
return {
label: line.slice(0, splitIndex).trim(),
value: line.slice(splitIndex + 1).trim(),
};
})
.filter((field): field is ImBizRecordField => !!field && !!field.label);
}
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】多条明细列表展示列解析-----------
/** 多条明细合并为列表时,取统一表头(以首条字段为准) */
export function resolveImBizRecordListColumnLabels(items: ImBizRecordItem[] = []): string[] {
if (!items.length) {
return [];
}
return resolveImBizRecordItemFields(items[0]).map((field) => field.label);
}
/** 按列名读取单条明细展示值 */
export function getImBizRecordFieldValueByLabel(item: ImBizRecordItem, label: string): string {
const field = resolveImBizRecordItemFields(item).find((itemField) => itemField.label === label);
return field?.value ?? '';
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】多条明细列表展示列解析-----------
/** 去除已有 imRecordId避免链接叠加 */
export function normalizeImPagePath(pagePath: string): string {
const [path, queryStr] = pagePath.split('?');
if (!queryStr) {
return path;
}
const params = new URLSearchParams(queryStr);
params.delete(IM_RECORD_QUERY_KEY);
const rest = params.toString();
return rest ? `${path}?${rest}` : path;
}
export function buildImRecordLinkPath(pagePath: string, recordId: string): string {
const normalized = normalizeImPagePath(pagePath);
const separator = normalized.includes('?') ? '&' : '?';
return `${normalized}${separator}${IM_RECORD_QUERY_KEY}=${encodeURIComponent(recordId)}`;
}
export function parseImRecordLinkPath(linkPath: string): { path: string; query: Record<string, string> } {
const [path, queryStr] = linkPath.split('?');
const query: Record<string, string> = {};
if (queryStr) {
new URLSearchParams(queryStr).forEach((value, key) => {
query[key] = value;
});
}
return { path, query };
}
// 保留导出,供旧代码引用
export { getImListCellDisplayValue };

View File

@@ -0,0 +1,64 @@
import type { Menu } from '/@/router/types';
import { PermissionModeEnum } from '/@/enums/appEnum';
import projectSetting from '/@/settings/projectSetting';
import { getAllParentPath } from '/@/router/helper/menuHelper';
import { usePermissionStoreWithOut } from '/@/store/modules/permission';
import { normalizeImPagePath } from './imBizRecordMessage';
/** 提取用于权限匹配的路由 path不含 query */
function extractImBizRecordPath(pagePath?: string): string {
const normalized = normalizeImPagePath(pagePath || '');
const path = normalized.split('?')[0] || '';
if (path.length > 1 && path.endsWith('/')) {
return path.slice(0, -1);
}
return path;
}
function getAuthorizedMenus(): Menu[] {
const permissionStore = usePermissionStoreWithOut();
const permMode = projectSetting.permissionMode;
if (permMode === PermissionModeEnum.ROUTE_MAPPING) {
return permissionStore.getFrontMenuList || [];
}
return permissionStore.getBackMenuList || [];
}
function flattenMenuPaths(menus: Menu[] = []): string[] {
const paths: string[] = [];
const walk = (items: Menu[]) => {
items.forEach((item) => {
if (item.path) {
paths.push(item.path);
}
if (typeof item.redirect === 'string' && item.redirect.startsWith('/')) {
paths.push(item.redirect.split('?')[0]);
}
if (item.children?.length) {
walk(item.children);
}
});
};
walk(menus);
return paths;
}
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】接收方无菜单权限时不展示业务明细-----------
/** 当前用户是否拥有 IM 业务明细对应功能页的菜单权限 */
export function hasImBizRecordPagePermission(pagePath?: string): boolean {
const targetPath = extractImBizRecordPath(pagePath);
if (!targetPath) {
return false;
}
const menus = getAuthorizedMenus();
if (!menus.length) {
return false;
}
const parentPaths = getAllParentPath(menus, targetPath);
if (parentPaths?.length) {
return true;
}
const menuPaths = flattenMenuPaths(menus);
return menuPaths.some((path) => path === targetPath);
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】接收方无菜单权限时不展示业务明细-----------

View File

@@ -3,7 +3,9 @@ import { createSessionStorage } from '/@/utils/cache';
import { useUserStoreWithOut } from '/@/store/modules/user';
import { fetchDeptMembers, fetchMessages } from './im.api';
import { getImDefaultStartTime } from './imSettings';
import { formatImMessagePreview } from './imMessageUtil';
import { syncImUnreadFromMembers } from './useImUnread';
import { isImChatPageActive } from './imSession';
export interface ImMemberItem {
id: string;
@@ -27,6 +29,8 @@ export interface ImMessageItem {
msgType?: string;
mine?: boolean;
createTime?: string;
/** 业务明细:接收方是否有对应功能页权限(仅发送方消息返回) */
receiverHasBizPagePermission?: boolean;
}
export interface ImMessageCacheEntry {
@@ -52,6 +56,48 @@ const sessionCache = createSessionStorage({ timeout: 60 * 60 });
let memorySnapshot: ImCacheSnapshot | null = null;
let prefetchPromise: Promise<void> | null = null;
let activeConversationId = '';
let activeTargetUserId = '';
let imChatWindowOpen = false;
type ImMessagesUpdatedListener = (conversationId: string) => void;
const messageUpdatedListeners = new Set<ImMessagesUpdatedListener>();
export function onImMessagesUpdated(listener: ImMessagesUpdatedListener) {
messageUpdatedListeners.add(listener);
return () => messageUpdatedListeners.delete(listener);
}
type ImChatWindowOpenListener = (open: boolean) => void;
const chatWindowOpenListeners = new Set<ImChatWindowOpenListener>();
export function onImChatWindowOpenChange(listener: ImChatWindowOpenListener) {
chatWindowOpenListeners.add(listener);
return () => chatWindowOpenListeners.delete(listener);
}
function notifyImMessagesUpdated(conversationId: string) {
if (!conversationId) {
return;
}
messageUpdatedListeners.forEach((listener) => listener(conversationId));
}
export function setImChatWindowOpen(open: boolean) {
if (imChatWindowOpen === open) {
return;
}
imChatWindowOpen = open;
chatWindowOpenListeners.forEach((listener) => listener(open));
}
export function isImChatWindowOpen() {
return imChatWindowOpen;
}
/** 弹窗或全页 IM 任一处于打开/激活状态 */
export function isImChatUiOpen() {
return imChatWindowOpen || isImChatPageActive();
}
function getCacheScopeKey(): string | null {
const userStore = useUserStoreWithOut();
@@ -94,11 +140,53 @@ export function setImActiveConversationId(conversationId: string) {
activeConversationId = conversationId || '';
}
export function getImActiveConversationId() {
return activeConversationId;
}
export function setImActiveTargetUserId(userId: string) {
activeTargetUserId = userId || '';
}
export function getImActiveTargetUserId() {
return activeTargetUserId;
}
/** 关闭聊天或离开会话时清除「正在查看」状态,恢复角标统计 */
export function clearImActiveSession() {
activeConversationId = '';
activeTargetUserId = '';
}
function ensureCachedMember(userId: string, seed: Partial<ImMemberItem>) {
const snap = ensureMemory();
const index = snap.members.findIndex((item) => item.id === userId);
if (index >= 0) {
return snap.members[index];
}
const created: ImMemberItem = {
id: userId,
username: seed.username || userId,
realname: seed.realname,
avatar: seed.avatar,
conversationId: seed.conversationId,
lastContent: seed.lastContent,
lastTime: seed.lastTime,
unreadCount: seed.unreadCount || 0,
};
snap.members = [created, ...snap.members];
snap.membersLoadedAt = Date.now();
saveToSession(snap);
return created;
}
export function clearImCache() {
const scope = getCacheScopeKey();
memorySnapshot = null;
prefetchPromise = null;
activeConversationId = '';
activeTargetUserId = '';
imChatWindowOpen = false;
if (scope) {
sessionCache.remove(`${CACHE_KEY}:${scope}`);
}
@@ -153,6 +241,7 @@ export function setCachedMessages(
loadedAt: Date.now(),
};
saveToSession(snap);
notifyImMessagesUpdated(conversationId);
}
export function appendCachedMessage(conversationId: string, msg: ImMessageItem) {
@@ -171,6 +260,7 @@ export function appendCachedMessage(conversationId: string, msg: ImMessageItem)
entry.records.push(msg);
entry.loadedAt = Date.now();
saveToSession(snap);
notifyImMessagesUpdated(conversationId);
}
export function patchCachedMember(
@@ -258,7 +348,8 @@ export function handleImChatSocket(data: Record<string, any>) {
}
const conversationId = data.conversationId as string;
const senderId = data.senderId as string;
const isActiveConversation = !!conversationId && conversationId === activeConversationId;
const isActiveConversation =
isImChatUiOpen() && !!conversationId && conversationId === activeConversationId;
if (isActiveConversation) {
const userStore = useUserStoreWithOut();
@@ -274,22 +365,35 @@ export function handleImChatSocket(data: Record<string, any>) {
mine: senderId === currentUserId,
createTime: data.createTime,
});
patchCachedMember(
senderId,
{
conversationId,
lastContent: data.content,
lastTime: data.createTime,
unreadCount: 0,
},
{ moveToTop: true },
);
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 {
ensureCachedMember(senderId, {
username: data.senderName as string,
realname: data.senderName as string,
avatar: data.senderAvatar as string,
conversationId,
lastContent: formatImMessagePreview(data.content, data.msgType),
lastTime: data.createTime,
});
patchCachedMember(
senderId,
{
conversationId,
lastContent: data.content,
lastContent: formatImMessagePreview(data.content, data.msgType),
lastTime: data.createTime,
},
{ unreadIncrement: 1 },

View File

@@ -0,0 +1,103 @@
import { getGloablEmojiIndex } from '/@/components/jeecg/comment/useComment';
import { parseImBizRecordPayload } from './imBizRecordMessage';
/** IM 消息类型 */
export const IM_MSG_TYPE_TEXT = 'text';
export const IM_MSG_TYPE_IMAGE = 'image';
export const IM_MSG_TYPE_BIZ_RECORD = 'biz_record';
/** 会话列表/摘要中的图片占位文案 */
export const IM_IMAGE_PREVIEW_TEXT = '[图片]';
export const IM_BIZ_RECORD_PREVIEW_TEXT = '[业务数据]';
/** 允许上传的图片 MIME 类型 */
export const IM_IMAGE_ACCEPT = 'image/jpeg,image/jpg,image/png,image/gif,image/webp';
/** 单张图片大小上限10MB */
export const IM_IMAGE_MAX_SIZE = 10 * 1024 * 1024;
/** emoji-mart 短码,如 :grinning: */
const EMOJI_COLONS_REGEX = /([^:]+)?(:[a-zA-Z0-9-_+]+:(:skin-tone-[2-6]:)?)/g;
/** 将 :grinning: 短码转为原生 Unicode 表情(兼容历史消息) */
export function resolveImEmojiText(text?: string) {
if (!text) {
return '';
}
const emojiIndex = getGloablEmojiIndex();
return text.replace(EMOJI_COLONS_REGEX, (match, before, colonCode) => {
const prefix = before || '';
if (prefix.endsWith('alt="') || prefix.endsWith('data-text="')) {
return match;
}
const emoji = emojiIndex.findEmoji(colonCode);
if (!emoji?.native) {
return match;
}
return prefix + emoji.native;
});
}
/** 根据消息类型格式化会话摘要(列表预览) */
export function formatImMessagePreview(content?: string, msgType?: string) {
if (msgType === IM_MSG_TYPE_IMAGE) {
return IM_IMAGE_PREVIEW_TEXT;
}
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】业务明细消息会话摘要-----------
if (msgType === IM_MSG_TYPE_BIZ_RECORD) {
const payload = parseImBizRecordPayload(content);
return payload?.pageTitle ? `${IM_BIZ_RECORD_PREVIEW_TEXT}${payload.pageTitle}` : IM_BIZ_RECORD_PREVIEW_TEXT;
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】业务明细消息会话摘要-----------
if (!content) {
return '';
}
const plain = content.replace(/<[^>]+>/g, '');
return resolveImEmojiText(plain);
}
/** 是否为图片消息 */
export function isImImageMessage(msgType?: string) {
return msgType === IM_MSG_TYPE_IMAGE;
}
/** 是否为纯文本消息(含表情) */
export function isImTextMessage(msgType?: string) {
return !msgType || msgType === IM_MSG_TYPE_TEXT;
}
export function isImBizRecordMessage(msgType?: string) {
return msgType === IM_MSG_TYPE_BIZ_RECORD;
}
/** 从表情面板选中项取可插入文本(优先原生 Unicode */
export function getEmojiInsertText(item?: Record<string, any>) {
if (!item || typeof item !== 'object' || item instanceof Event) {
return '';
}
// emoji-mart 返回的 EmojiData 对象
if (typeof item.getSkin === 'function') {
const skinTone = item.skin ?? item.skin_tone ?? null;
const emoji = item.getSkin(skinTone);
if (emoji?.native) {
return emoji.native;
}
}
if (typeof item.native === 'string' && item.native) {
return item.native;
}
const emojiIndex = getGloablEmojiIndex();
if (item.colons) {
const found = emojiIndex.findEmoji(item.colons);
if (found?.native) {
return found.native;
}
}
if (item.id) {
const found = emojiIndex.findEmoji(`:${item.id}:`);
if (found?.native) {
return found.native;
}
}
return '';
}

View File

@@ -0,0 +1,28 @@
import type { ImPageListSnapshot } from './imPageListUtil';
type ImPageListSnapshotFactory = () => ImPageListSnapshot | null;
let activeProvider: ImPageListSnapshotFactory | null = null;
/** 列表页注册 IM 明细快照提供器(页面卸载时自动注销) */
export function registerImPageListProvider(factory: ImPageListSnapshotFactory) {
activeProvider = factory;
return () => {
if (activeProvider === factory) {
activeProvider = null;
}
};
}
/** 打开 IM 弹窗时采集当前列表页快照 */
export function captureImPageListSnapshot(): ImPageListSnapshot | null {
if (!activeProvider) {
return null;
}
try {
return activeProvider();
} catch (error) {
console.warn('[IM] 采集列表页快照失败', error);
return null;
}
}

View File

@@ -0,0 +1,161 @@
import type { BasicColumn } from '/@/components/Table';
export interface ImPageListColumn {
title: string;
dataIndex: string;
width?: number;
}
export interface ImPageListSnapshot {
title: string;
pagePath: string;
rowKey: string;
columns: ImPageListColumn[];
/** 保留列表原始列配置,用于 customRender / 字典翻译 */
sourceColumns?: BasicColumn[];
records: Recordable[];
}
export interface ImBizRecordField {
label: string;
value: string;
}
/** 提取可用于 IM 展示的列表列(排除操作列、隐藏列) */
export function buildImListColumns(columns: BasicColumn[] = []): ImPageListColumn[] {
return columns
.filter((col) => {
const dataIndex = col.dataIndex;
if (!dataIndex || dataIndex === 'action') {
return false;
}
if (col.defaultHidden || col.ifShow === false) {
return false;
}
if (col.flag === 'ACTION') {
return false;
}
return true;
})
.map((col) => ({
title: String(col.title || col.dataIndex),
dataIndex: String(col.dataIndex),
width: typeof col.width === 'number' ? col.width : undefined,
}));
}
/** 单元格展示值(优先字典翻译字段) */
export function getImListCellDisplayValue(record: Recordable, dataIndex: string): string {
const dictKey = dataIndex.endsWith('_dictText') ? dataIndex : `${dataIndex}_dictText`;
const dictVal = record[dictKey];
if (dictVal !== undefined && dictVal !== null && dictVal !== '') {
return String(dictVal);
}
const val = record[dataIndex];
if (val === undefined || val === null || val === '') {
return '';
}
return String(val);
}
function normalizeRenderValue(value: unknown): string {
if (value === undefined || value === null || value === '') {
return '';
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return String(value);
}
/** 按列表列配置解析一行明细(支持 customRender 与 _dictText */
export function buildImBizRecordFields(
row: Recordable,
columns: ImPageListColumn[],
sourceColumns: BasicColumn[] = [],
): ImBizRecordField[] {
const sourceColumnMap = new Map<string, BasicColumn>();
sourceColumns.forEach((col) => {
if (col.dataIndex) {
sourceColumnMap.set(String(col.dataIndex), col);
}
});
const fields: ImBizRecordField[] = [];
columns.forEach((col, index) => {
const sourceCol = sourceColumnMap.get(col.dataIndex);
let value = '';
if (sourceCol?.customRender && typeof sourceCol.customRender === 'function') {
try {
const rendered = sourceCol.customRender({
text: row[col.dataIndex],
value: row[col.dataIndex],
record: row,
column: sourceCol,
index,
} as any);
value = normalizeRenderValue(rendered);
} catch {
value = getImListCellDisplayValue(row, col.dataIndex);
}
} else {
value = getImListCellDisplayValue(row, col.dataIndex);
}
if (!value || value === '-') {
return;
}
fields.push({
label: col.title,
value,
});
});
return fields;
}
/** 构建 IM 列表页快照 */
export function buildImPageListSnapshot(options: {
title?: string;
pagePath?: string;
rowKey?: string;
columns?: BasicColumn[];
sourceColumns?: BasicColumn[];
records?: Recordable[];
}): ImPageListSnapshot | null {
const records = (options.records || []).map((item) => ({ ...item }));
if (!records.length) {
return null;
}
const sourceColumns = options.sourceColumns || options.columns || [];
const columns = buildImListColumns(sourceColumns);
if (!columns.length) {
return null;
}
return {
title: options.title || '',
pagePath: options.pagePath || '',
rowKey: options.rowKey || 'id',
columns,
sourceColumns,
records,
};
}
/** 将选中明细格式化为 IM 文本消息 */
export function formatImListRowsMessage(pageTitle: string, columns: ImPageListColumn[], rows: Recordable[]): string {
const lines: string[] = [`${pageTitle || '业务数据'}`];
rows.forEach((row, index) => {
if (rows.length > 1) {
lines.push(`--- 第${index + 1}条 ---`);
}
columns.forEach((col) => {
const value = getImListCellDisplayValue(row, col.dataIndex);
if (value) {
lines.push(`${col.title}: ${value}`);
}
});
if (index < rows.length - 1) {
lines.push('');
}
});
return lines.join('\n').trim();
}

View File

@@ -0,0 +1,54 @@
import type { Router } from 'vue-router';
import { getMenus } from '/@/router/menus';
import { useI18n } from '/@/hooks/web/useI18n';
import { captureImPageListSnapshot } from './imPageListRegistry';
/** 与 useTitle 一致online 等动态路由从菜单匹配真实页面名 */
function getMatchingRouterName(menus: any[], path: string): string {
for (let i = 0, len = menus.length; i < len; i++) {
const item = menus[i];
if (item.path === path && !item.redirect && !item.paramPath) {
return item.meta?.title || '';
}
if (item.children?.length) {
const result = getMatchingRouterName(item.children, path);
if (result) {
return result;
}
}
}
return '';
}
/** 解析当前路由对应的功能页名称(与浏览器标题逻辑一致) */
export async function resolveImPageTitle(router: Router): Promise<string> {
const route = router.currentRoute.value;
const { t } = useI18n();
if (route.params && Object.keys(route.params).length) {
const menus = await getMenus();
const menuTitle = getMatchingRouterName(menus, route.fullPath);
if (menuTitle) {
return t(menuTitle);
}
}
const metaTitle = route.meta?.title as string | undefined;
return metaTitle ? t(metaTitle) : '';
}
/** 打开 IM 弹窗时快照当前页上下文 */
export async function captureImPageContext(router: Router) {
const title = await resolveImPageTitle(router);
if (!title) {
return null;
}
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】列表页打开 IM 时快照明细数据-----------
const listSnapshot = captureImPageListSnapshot();
return {
title,
path: router.currentRoute.value.fullPath,
listSnapshot: listSnapshot || null,
};
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】列表页打开 IM 时快照明细数据-----------
}

View File

@@ -0,0 +1,133 @@
import { router } from '/@/router';
import { setImChatWindowOpen } from './imCache';
import { IM_RECORD_QUERY_KEY, parseImRecordLinkPath } from './imBizRecordMessage';
import { requestCloseImChatModal } from './imSession';
const IM_LOCATE_SESSION_KEY = '__im_locate_record__';
/** 同路由 IM 链接跳转时 watch 可能不触发,通过事件补充定位 */
export const IM_RECORD_LOCATE_EVENT = 'im-record-locate';
/** 标签页刷新时取消 IM 定位(清除高亮与待定位标记) */
export const IM_RECORD_LOCATE_CLEAR_EVENT = 'im-record-locate-clear';
/** 暂存待定位记录(避免把 imRecordId 写入路由导致 fullPath 变化、页面 remount */
export function stashImLocateRecord(path: string, recordId: string) {
sessionStorage.setItem(
IM_LOCATE_SESSION_KEY,
JSON.stringify({
path,
recordId,
}),
);
}
/** 读取并消费一次性定位记录 */
export function consumeImLocateRecord(expectedPath: string): string {
const raw = sessionStorage.getItem(IM_LOCATE_SESSION_KEY);
if (!raw) {
return '';
}
try {
const data = JSON.parse(raw) as { path?: string; recordId?: string };
if (data.path === expectedPath && data.recordId) {
sessionStorage.removeItem(IM_LOCATE_SESSION_KEY);
return String(data.recordId);
}
} catch {
sessionStorage.removeItem(IM_LOCATE_SESSION_KEY);
}
return '';
}
/** 从 IM 消息链接跳转到业务列表并定位记录 */
export async function navigateImBizRecordLink(linkPath: string) {
const { path, query } = parseImRecordLinkPath(linkPath);
const recordId = query[IM_RECORD_QUERY_KEY];
requestCloseImChatModal();
setImChatWindowOpen(false);
const nextQuery = { ...query };
delete nextQuery[IM_RECORD_QUERY_KEY];
if (recordId) {
stashImLocateRecord(path, String(recordId));
}
// 不把 imRecordId 带入路由,避免 fullPath 变化触发 PageLayout remount
await router.push({ path, query: nextQuery });
if (recordId) {
window.dispatchEvent(
new CustomEvent(IM_RECORD_LOCATE_EVENT, {
detail: { path, recordId: String(recordId) },
}),
);
}
}
/** 从 query 中移除 IM 定位参数 */
export function stripImRecordQuery(query: Recordable = {}) {
if (!query[IM_RECORD_QUERY_KEY]) {
return { ...query };
}
const nextQuery = { ...query };
delete nextQuery[IM_RECORD_QUERY_KEY];
return nextQuery;
}
/** 清除 session 待定位标记,并通知列表页取消高亮 */
export function clearImRecordLocateState() {
sessionStorage.removeItem(IM_LOCATE_SESSION_KEY);
window.dispatchEvent(new CustomEvent(IM_RECORD_LOCATE_CLEAR_EVENT));
}
/** 定位完成后仅更新浏览器地址栏,不触发 router 导航与页面 remount */
export function removeImRecordQueryFromRoute() {
const route = router.currentRoute.value;
if (!route.query[IM_RECORD_QUERY_KEY]) {
return;
}
const query = { ...route.query };
delete query[IM_RECORD_QUERY_KEY];
const { href } = router.resolve({
path: route.path,
query,
hash: route.hash,
});
window.history.replaceState(window.history.state, '', href);
}
/** 滚动到指定表格行 */
export function scrollToImRecordRow(recordId: string): boolean {
const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(recordId) : recordId.replace(/"/g, '\\"');
const rowEl = document.querySelector(`tr[data-row-key="${escaped}"]`) as HTMLElement | null;
if (!rowEl) {
return false;
}
rowEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
return true;
}
/** 等待 DOM 渲染后多次尝试滚动 */
export async function scrollToImRecordRowWithRetry(recordId: string, retry = 10, intervalMs = 100): Promise<boolean> {
for (let i = 0; i < retry; i++) {
if (scrollToImRecordRow(recordId)) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return false;
}
/** 解析当前页面待定位 recordId优先 session 一次性标记,兼容 URL 直链) */
export function resolveImLocateRecordId(path: string, queryRecordId?: string | string[]) {
const fromSession = consumeImLocateRecord(path);
if (fromSession) {
return fromSession;
}
if (Array.isArray(queryRecordId)) {
return queryRecordId[0] ? String(queryRecordId[0]) : '';
}
return queryRecordId ? String(queryRecordId) : '';
}

View File

@@ -0,0 +1,95 @@
import { ref } from 'vue';
import type { ImPageListSnapshot } from './imPageListUtil';
/** 打开 IM 弹窗时快照的当前功能页上下文 */
export interface ImPageContext {
title: string;
path: string;
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】列表页打开 IM 时快照明细数据-----------
listSnapshot?: ImPageListSnapshot | null;
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】列表页打开 IM 时快照明细数据-----------
}
/** 全页 IM 聊天是否处于激活状态菜单「IM聊天」页签 */
const imChatPageActive = ref(false);
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】弹窗打开时快照背后功能页名称-----------
const imPageContext = ref<ImPageContext | null>(null);
export function setImPageContext(context: ImPageContext | null) {
imPageContext.value = context;
}
export function useImPageContext() {
return imPageContext;
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】弹窗打开时快照背后功能页名称-----------
type ImOpenTargetListener = (targetUserId: string) => void | Promise<void>;
const openTargetListeners = new Set<ImOpenTargetListener>();
export function setImChatPageActive(active: boolean) {
imChatPageActive.value = active;
}
export function isImChatPageActive() {
return imChatPageActive.value;
}
/** 供头部图标等 UI 绑定禁用态 */
export function useImChatPageActive() {
return imChatPageActive;
}
/** 是否允许打开 IM 弹窗(全页 IM 已打开时不允许) */
export function canOpenImChatModal() {
return !imChatPageActive.value;
}
export function onImOpenTargetRequest(listener: ImOpenTargetListener) {
openTargetListeners.add(listener);
return () => openTargetListeners.delete(listener);
}
async function notifyOpenTarget(targetUserId: string) {
for (const listener of openTargetListeners) {
await listener(targetUserId);
}
}
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】业务明细消息链接跳转时关闭 IM 弹窗-----------
type ImCloseModalListener = () => void;
const closeModalListeners = new Set<ImCloseModalListener>();
export function onImChatModalCloseRequest(listener: ImCloseModalListener) {
closeModalListeners.add(listener);
return () => closeModalListeners.delete(listener);
}
export function requestCloseImChatModal() {
closeModalListeners.forEach((listener) => listener());
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】业务明细消息链接跳转时关闭 IM 弹窗-----------
/**
* 统一打开 IM 聊天入口:
* - 已在全页 IM → 切换目标会话
* - 否则 → 返回 'modal',由调用方打开 ImChatModal
*/
export async function openImChat(options?: {
targetUserId?: string;
pageContext?: ImPageContext | null;
}): Promise<'page' | 'modal'> {
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】弹窗打开时快照背后功能页名称-----------
if (options?.pageContext !== undefined) {
setImPageContext(options.pageContext);
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】弹窗打开时快照背后功能页名称-----------
if (isImChatPageActive()) {
if (options?.targetUserId) {
await notifyOpenTarget(options.targetUserId);
}
return 'page';
}
return 'modal';
}

View File

@@ -0,0 +1,39 @@
import { useRouter } from 'vue-router';
import { useMessage } from '/@/hooks/web/useMessage';
import { captureImPageContext } from './imPageTitle';
import { canOpenImChatModal, openImChat, type ImPageContext } from './imSession';
export interface OpenImChatModalOptions {
targetUserId?: string;
pageContext?: ImPageContext | null;
}
/** 从任意功能页打开 IM 弹窗(自动快照当前页功能名) */
export function useImChat() {
const router = useRouter();
const { createMessage } = useMessage();
async function openChatModal(
openModal: (visible: boolean, data?: OpenImChatModalOptions) => void,
options?: { targetUserId?: string; pageContext?: ImPageContext | null },
) {
if (!canOpenImChatModal()) {
createMessage.warning('当前已在 IM 聊天页面,请直接使用页面内聊天');
return 'page' as const;
}
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】弹窗打开时快照背后功能页名称-----------
let pageContext = options?.pageContext;
if (pageContext === undefined) {
pageContext = await captureImPageContext(router);
}
const mode = await openImChat({ targetUserId: options?.targetUserId, pageContext });
if (mode === 'modal') {
openModal(true, { targetUserId: options?.targetUserId, pageContext });
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】弹窗打开时快照背后功能页名称-----------
return mode;
}
return { openChatModal };
}

View File

@@ -1,12 +1,58 @@
import { ref } from 'vue';
import { fetchDeptMembers } from './im.api';
import { getCachedMembers, isMembersCacheStale, setCachedMembers } from './imCache';
import {
getCachedMembers,
getImActiveConversationId,
getImActiveTargetUserId,
isImChatUiOpen,
isMembersCacheStale,
setCachedMembers,
} from './imCache';
const totalUnread = ref(0);
/** 有未读消息的对话数量(与聊天消息列表条数一致) */
const conversationUnreadCount = ref(0);
let refreshing = false;
export function syncImUnreadFromMembers(members: Array<{ unreadCount?: number }>) {
totalUnread.value = (members || []).reduce((sum, item) => sum + (item.unreadCount || 0), 0);
function isActiveSessionMember(item: { id?: string; conversationId?: string }) {
if (!isImChatUiOpen()) {
return false;
}
const activeConvId = getImActiveConversationId();
const activeTargetId = getImActiveTargetUserId();
if (activeConvId && item.conversationId === activeConvId) {
return true;
}
if (activeTargetId && item.id === activeTargetId) {
return true;
}
return false;
}
/** 正在查看的会话不计入顶部未读角标(仅计算展示,不回写缓存) */
function calcUnreadStats(members: Array<{ unreadCount?: number; conversationId?: string; id?: string }>) {
if (isImChatUiOpen()) {
return { messageUnread: 0, conversationUnread: 0 };
}
let messageUnread = 0;
let conversationUnread = 0;
for (const item of members || []) {
if (isActiveSessionMember(item)) {
continue;
}
const count = item.unreadCount || 0;
if (count > 0) {
messageUnread += count;
conversationUnread += 1;
}
}
return { messageUnread, conversationUnread };
}
export function syncImUnreadFromMembers(members: Array<{ unreadCount?: number; conversationId?: string; id?: string }>) {
const { messageUnread, conversationUnread } = calcUnreadStats(members);
totalUnread.value = messageUnread;
conversationUnreadCount.value = conversationUnread;
}
export async function refreshImUnread(force = false) {
@@ -22,9 +68,9 @@ export async function refreshImUnread(force = false) {
}
refreshing = true;
try {
const members = await fetchDeptMembers();
setCachedMembers(members || []);
syncImUnreadFromMembers(members || []);
const members = ((await fetchDeptMembers()) || []) as Array<{ unreadCount?: number; conversationId?: string; id?: string }>;
setCachedMembers(members);
syncImUnreadFromMembers(members);
} finally {
refreshing = false;
}
@@ -33,6 +79,7 @@ export async function refreshImUnread(force = false) {
export function useImUnread() {
return {
totalUnread,
conversationUnreadCount,
refreshImUnread,
syncImUnreadFromMembers,
};

View File

@@ -0,0 +1,167 @@
<template>
<a-spin :spinning="loading">
<a-list item-layout="horizontal" :data-source="unreadList" :locale="locale">
<template #renderItem="{ item }">
<a-list-item class="im-chat-msg-item" @click="handleOpenChat(item)">
<template #actions>
<span class="im-chat-msg-time">{{ formatTime(item.lastTime) }}</span>
</template>
<a-list-item-meta>
<template #title>
<div class="im-chat-msg-title">
<span class="im-chat-msg-name">{{ item.realname || item.username }}</span>
<a-badge :count="item.unreadCount" :overflow-count="99" />
</div>
</template>
<template #description>
<div class="im-chat-msg-preview">{{ formatPreview(item.lastContent) }}</div>
</template>
<template #avatar>
<a-badge dot :offset="[-2, 2]">
<a-avatar :src="getAvatarUrl(item.avatar)">
{{ (item.realname || item.username || '?').slice(0, 1) }}
</a-avatar>
</a-badge>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-spin>
<ImChatModal @register="registerImChatModal" />
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import dayjs from 'dayjs';
import { useModal } from '/@/components/Modal';
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
import { fetchDeptMembers } from '/@/views/system/im/im.api';
import { formatImMessagePreview } from '/@/views/system/im/imMessageUtil';
import { syncImUnreadFromMembers } from '/@/views/system/im/useImUnread';
import ImChatModal from '/@/views/system/im/ImChatModal.vue';
import { openImChat } from '/@/views/system/im/imSession';
defineOptions({ name: 'SysImChatMessageList' });
const emit = defineEmits<{
(e: 'closeModal'): void;
}>();
interface ChatMemberItem {
id: string;
username: string;
realname?: string;
avatar?: string;
conversationId?: string;
lastContent?: string;
lastTime?: string;
unreadCount?: number;
}
const loading = ref(false);
const members = ref<ChatMemberItem[]>([]);
const [registerImChatModal, { openModal: openImChatModal }] = useModal();
const unreadList = computed(() =>
members.value
.filter((item) => (item.unreadCount || 0) > 0)
.sort((a, b) => dayjs(b.lastTime || 0).valueOf() - dayjs(a.lastTime || 0).valueOf()),
);
const locale = computed(() => ({
emptyText: loading.value ? ' ' : '暂无未读聊天消息',
}));
function getAvatarUrl(avatar?: string) {
return avatar ? getFileAccessHttpUrl(avatar) : '';
}
function formatPreview(content?: string) {
return formatImMessagePreview(content) || '发来一条新消息';
}
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');
}
async function reload(silent = false) {
//update-begin---author:cursor ---date:20250528 for【IM聊天-OA】聊天消息列表静默刷新避免 spin 遮罩闪烁-----------
const showLoading = !silent && members.value.length === 0;
if (showLoading) {
loading.value = true;
}
try {
members.value = ((await fetchDeptMembers()) || []) as ChatMemberItem[];
syncImUnreadFromMembers(members.value);
} catch {
if (!silent) {
members.value = [];
}
} finally {
if (showLoading) {
loading.value = false;
}
}
//update-end---author:cursor ---date:20250528 for【IM聊天-OA】聊天消息列表静默刷新避免 spin 遮罩闪烁-----------
}
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-end---author:xsl ---date:20260528 for【IM聊天-OA】统一 IM 打开入口,全页优先-----------
emit('closeModal');
}
defineExpose({ reload });
</script>
<style lang="less" scoped>
.im-chat-msg-item {
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background: #f5f7fa;
}
}
.im-chat-msg-title {
display: flex;
align-items: center;
gap: 8px;
}
.im-chat-msg-name {
font-weight: 500;
color: rgba(0, 0, 0, 0.88);
}
.im-chat-msg-preview {
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 520px;
}
.im-chat-msg-time {
color: #999;
font-size: 12px;
white-space: nowrap;
}
</style>

View File

@@ -2,16 +2,11 @@
<a-list item-layout="horizontal" :data-source="messageList" :locale="locale">
<template #loadMore>
<div
v-if="messageList && messageList.length > 0 && !loadEndStatus && !loadingMoreStatus"
v-if="messageList && messageList.length > 0"
:style="{ textAlign: 'center', marginTop: '12px', height: '32px', lineHeight: '32px' }"
>
<a-button @click="onLoadMore">加载更多</a-button>
</div>
<div
v-if="messageList && messageList.length > 0 && loadEndStatus"
:style="{ textAlign: 'center', marginTop: '12px', height: '32px', lineHeight: '32px' }"
>
没有更多了
<a-button v-if="!loadEndStatus && !loadingMoreStatus" @click="onLoadMore">加载更多</a-button>
<span v-else-if="loadEndStatus">没有更多了</span>
</div>
</template>
@@ -19,7 +14,7 @@
<a-list-item :style="{ background: item?.izTop && item.izTop == 1 ? '#f7f7f7' : 'auto' }">
<template #actions>
<span>{{formatData(item.sendTime)}}</span>
<a-rate class="antd-rate" :value="item.starFlag=='1'?1:0" :count="1" @click="clickStar(item)" style="cursor: pointer" disabled />
<a-rate v-if="item.busType !== 'im_chat'" class="antd-rate" :value="item.starFlag=='1'?1:0" :count="1" @click="clickStar(item)" style="cursor: pointer" disabled />
</template>
<a-list-item-meta>
@@ -88,6 +83,17 @@
<a-avatar v-else style="background: #79919d"><AlertOutlined style="font-size: 16px"/></a-avatar>
</template>
<template v-else-if="item.busType=='im_chat'">
<a-badge dot v-if="noRead(item)" class="msg-no-read">
<a-avatar :src="getImAvatar(item.imAvatar)" style="background: #5b8ff9">
{{ getImAvatarText(item) }}
</a-avatar>
</a-badge>
<a-avatar v-else :src="getImAvatar(item.imAvatar)" style="background: #5b8ff9">
{{ getImAvatarText(item) }}
</a-avatar>
</template>
<template v-else>
<a-badge dot v-if="noRead(item)" class="msg-no-read">
<a-avatar style="background: #79919d"><bell-filled style="font-size: 16px" title="未读消息"/></a-avatar>
@@ -100,6 +106,8 @@
</template>
</a-list>
<ImChatModal @register="registerImChatModal" />
<keep-alive>
<component v-if="currentModal" v-bind="bindParams" :key="currentModal" :is="currentModal" @register="modalRegCache[currentModal].register" />
</keep-alive>
@@ -112,6 +120,11 @@
import {getGloablEmojiIndex, useEmojiHtml} from "/@/components/jeecg/comment/useComment";
import { ref, h, watch } from "vue";
import dayjs from 'dayjs';
import { useModal } from '/@/components/Modal';
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
import ImChatModal from '/@/views/system/im/ImChatModal.vue';
import { openImChat } from '/@/views/system/im/imSession';
import { isImChatNotice } from './imChatNoticeAdapter';
export default {
name: 'SysMessageList',
@@ -124,6 +137,7 @@
InteractionOutlined,
AlertOutlined,
GatewayOutlined,
ImChatModal,
},
props:{
star: {
@@ -145,26 +159,41 @@
},
emits:['close', 'detail', 'clear', 'close-modal'],
setup(props, {emit}){
const { messageList,loadEndStatus,loadingMoreStatus,onLoadMore,noRead, getMsgCategory, getHrefText, searchParams, reset, loadData, updateStarMessage } = useSysMessage(setLocaleText);
const { messageList,loadEndStatus,loadingMoreStatus,onLoadMore,noRead, getMsgCategory, getHrefText, searchParams, reset, loadData, reloadFresh, updateStarMessage } = useSysMessage(setLocaleText);
//系统消息
const messageCount = ref(0);
const [registerImChatModal, { openModal: openImChatModal }] = useModal();
function reload(params){
function getImAvatar(avatar) {
return avatar ? getFileAccessHttpUrl(avatar) : '';
}
function getImAvatarText(item) {
const text = item?.titile || '';
const name = text.split('')[0] || '?';
return name.slice(0, 1);
}
function reload(params, silent = false){
let { fromUser, rangeDateKey, rangeDate, noticeType } = params;
searchParams.fromUser = fromUser||'';
searchParams.rangeDateKey = rangeDateKey||'';
searchParams.rangeDate = rangeDate||[];
searchParams.noticeType = noticeType || '';
//list列表为空时赋初始值
locale.value = { locale: { emptyText: `<a-empty />` }};
if(props.star===true){
searchParams.starFlag = '1'
}else{
searchParams.starFlag = ''
}
reset();
loadData();
//update-begin---author:cursor ---date:20250528 for【IM聊天-OA】消息弹窗静默刷新避免空状态在「暂无数据」与「还剩余未读」间闪烁-----------
if (silent) {
reloadFresh(true);
} else {
reset();
loadData();
}
//update-end---author:cursor ---date:20250528 for【IM聊天-OA】消息弹窗静默刷新避免空状态在「暂无数据」与「还剩余未读」间闪烁-----------
}
function clickStar(item){
@@ -191,7 +220,17 @@
const emojiIndex = getGloablEmojiIndex()
const { getHtml } = useEmojiHtml(emojiIndex);
function showMessageDetail(record){
async function showMessageDetail(record){
if (isImChatNotice(record)) {
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】统一 IM 打开入口,全页优先-----------
const mode = await openImChat({ targetUserId: record.imTargetUserId, pageContext: null });
if (mode === 'modal') {
openImChatModal(true, { targetUserId: record.imTargetUserId, pageContext: null });
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】统一 IM 打开入口,全页优先-----------
emit('close-modal');
return;
}
record.readFlag = '1'
goPage(record);
emit('close', record.id)
@@ -261,6 +300,11 @@
//监听信息数量
watch(() => props.messageCount, (value) => {
messageCount.value = value;
//update-begin---author:cursor ---date:20250528 for【IM聊天-OA】未读数变化时仅更新空状态文案不触发整表刷新-----------
if (messageList.value.length === 0 && loadEndStatus.value) {
setLocaleText();
}
//update-end---author:cursor ---date:20250528 for【IM聊天-OA】未读数变化时仅更新空状态文案不触发整表刷新-----------
}, { immediate: true })
return {
@@ -281,6 +325,9 @@
bindParams,
locale,
formatData,
registerImChatModal,
getImAvatar,
getImAvatarText,
};
},
};

View File

@@ -7,7 +7,6 @@
wrapClassName="sys-msg-modal"
:width="800"
:footer="null"
destroyOnClose
>
<template #title>
<div class="sys-msg-modal-title">
@@ -36,16 +35,18 @@
>
标星消息
</div>
<div
@click="(e) => handleChangeTab(e, 'chat')"
role="tab"
aria-disabled="false"
aria-selected="false"
class="ant-tabs-tab im-chat-msg-tab"
:class="{ 'ant-tabs-tab-active': activeKey == 'chat' }"
>
聊天消息
<a-badge v-if="imConversationUnreadCount > 0" :count="imConversationUnreadCount" :overflow-count="99" class="im-chat-msg-tab-badge" />
</div>
</div>
<div
class="ant-tabs-ink-bar ant-tabs-ink-bar-animated"
:style="{
transform: activeKey == 'all' ? 'translate3d(130px, 0px, 0px)' : 'translate3d(215px, 0px, 0px)',
display: 'block',
width: '88px',
height: '1px'
}"
></div>
</div>
</div>
</div>
@@ -53,7 +54,7 @@
<!-- 头部图标 -->
<div class="icon-right">
<div class="icons">
<a-popover placement="bottomRight" :overlayStyle="{ width: '400px' }" trigger="click" v-model:open="showSearch">
<a-popover v-show="activeKey !== 'chat'" placement="bottomRight" :overlayStyle="{ width: '400px' }" trigger="click" v-model:open="showSearch">
<template #content>
<div>
<span class="search-label">回复提到我的人?</span>
@@ -121,12 +122,17 @@
</template>
<a-tab-pane tab="全部消息" key="all" forceRender>
<sys-message-list :isLowApp="isLowApp" ref="allMessageRef" @close="hrefThenClose" @detail="showDetailModal" @clear="clearAll" :messageCount="messageCount" @closeModal="closeModal"/>
<sys-message-list :isLowApp="isLowApp" ref="allMessageRef" @close="hrefThenClose" @detail="showDetailModal" @clear="clearAll" :messageCount="systemMessageCount" @closeModal="closeModal"/>
</a-tab-pane>
<!-- 标星 -->
<a-tab-pane tab="标星消息" key="star" forceRender>
<sys-message-list :isLowApp="isLowApp" ref="starMessageRef" star @close="hrefThenClose" @detail="showDetailModal" @clear="clearAll" :messageCount="messageCount" @closeModal="closeModal" :cancelStarAfterDel="true"/>
<sys-message-list :isLowApp="isLowApp" ref="starMessageRef" star @close="hrefThenClose" @detail="showDetailModal" @clear="clearAll" :messageCount="systemMessageCount" @closeModal="closeModal" :cancelStarAfterDel="true"/>
</a-tab-pane>
<!-- 聊天消息 -->
<a-tab-pane tab="聊天消息" key="chat">
<sys-im-chat-message-list ref="chatMessageRef" @closeModal="closeModal" />
</a-tab-pane>
</a-tabs>
</div>
@@ -141,11 +147,14 @@
import { BasicModal, useModalInner, useModal } from '/@/components/Modal';
import { FilterOutlined, CloseOutlined, BellFilled, ExclamationOutlined, PlusOutlined } from '@ant-design/icons-vue';
import JSelectUser from '/@/components/Form/src/jeecg/components/JSelectUser.vue';
import { ref, unref, reactive, computed } from 'vue';
import { ref, unref, reactive, computed, onMounted, onUnmounted } from 'vue';
// import SysMessageList from './SysMessageList.vue'
import UserSelectModal from '/@/components/Form/src/jeecg/components/modal/UserSelectModal.vue'
import DetailModal from '/@/views/monitor/mynews/DetailModal.vue';
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';
import calendar from '/@/assets/icons/calendarNotice.png';
import folder from '/@/assets/icons/folderNotice.png';
import system from '/@/assets/icons/systemNotice.png';
@@ -163,22 +172,26 @@
JSelectUser,
// 代码逻辑说明: 【QQYUN-8241】emoji-mart-vue-fast库异步加载
SysMessageList: createAsyncComponent(() => import('./SysMessageList.vue')),
SysImChatMessageList: createAsyncComponent(() => import('./SysImChatMessageList.vue')),
// SysMessageList,
UserSelectModal,
PlusOutlined,
DetailModal
},
props:{
messageCount: {
systemMessageCount: {
type: Number,
default: 0
}
},
emits:['register', 'refresh'],
setup(_p, {emit}) {
setup(props, {emit}) {
const allMessageRef = ref();
const starMessageRef = ref();
const chatMessageRef = ref();
const activeKey = ref('all');
const { totalUnread: imUnreadCount, conversationUnreadCount: imConversationUnreadCount, refreshImUnread } = useImUnread();
//通知类型
const noticeType = ref('');
//通知类型数组
@@ -187,11 +200,12 @@
{ key: 'flow', text: '流程通知', active: false, img: flow },
{ key: 'plan', text: '日程通知', active: false, img: calendar },
{ key: 'file', text: '知识通知', active: false, img: folder },
{ key: 'chat', text: '聊天消息', active: false, img: collaboration },
]);
const noticeImg = ref('');
function handleChangeTab(e, key) {
activeKey.value = key;
loadData();
loadData(false);
}
function handleChangePanel(key) {
activeKey.value = key;
@@ -205,7 +219,19 @@
rangeDate: [],
});
function loadData(){
//update-begin---author:cursor ---date:20250528 for【IM聊天-OA】消息弹窗静默刷新避免打开时列表闪烁-----------
let loadDataTimer = null;
function loadData(silent = false){
if (loadDataTimer) {
clearTimeout(loadDataTimer);
}
loadDataTimer = window.setTimeout(() => {
loadDataTimer = null;
doLoadData(silent);
}, silent ? 200 : 0);
}
function doLoadData(silent = false){
let params = {
fromUser: searchParams.fromUser,
rangeDateKey: searchParams.rangeDateKey,
@@ -214,12 +240,17 @@
}
if(activeKey.value == 'all'){
getRefPromise(allMessageRef).then(() => {
allMessageRef.value.reload(params);
allMessageRef.value.reload(params, silent);
});
}else{
starMessageRef.value.reload(params);
} else if (activeKey.value == 'chat') {
getRefPromise(chatMessageRef).then(() => {
chatMessageRef.value.reload(silent);
});
} else {
starMessageRef.value.reload(params, silent);
}
}
//update-end---author:cursor ---date:20250528 for【IM聊天-OA】消息弹窗静默刷新避免打开时列表闪烁-----------
const isLowApp = ref(false);
//useModalInner
@@ -238,8 +269,9 @@
}
delete data.noticeType;
}
//每次弹窗打开 加载最新数据
loadData();
//每次弹窗打开 后台静默加载最新数据
refreshImUnread(false);
loadData(true);
if(data){
isLowApp.value = data.isLowApp||false
}else{
@@ -404,6 +436,36 @@
}
loadData();
}
function onModalWebSocket(data) {
if (data.cmd === 'chat') {
handleImChatSocket(data);
refreshImUnread(false);
if (isImChatUiOpen()) {
return;
}
if (activeKey.value === 'chat') {
getRefPromise(chatMessageRef).then(() => {
chatMessageRef.value?.reload?.(true);
});
} else if (activeKey.value === 'all' || activeKey.value === 'star') {
loadData(true);
}
return;
}
if (data.cmd === 'topic' || data.cmd === 'user') {
loadData(true);
}
}
onMounted(() => {
ensureWebSocketConnected();
onWebSocket(onModalWebSocket);
});
onUnmounted(() => {
offWebSocket(onModalWebSocket);
});
return {
conditionStr,
@@ -431,6 +493,9 @@
allMessageRef,
starMessageRef,
chatMessageRef,
imUnreadCount,
imConversationUnreadCount,
registerDetail,
showDetailModal,
isLowApp,
@@ -536,24 +601,43 @@
border: 0;
outline: none;
cursor: pointer;
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: transparent;
transition: background-color 0.2s;
}
}
.ant-tabs-tab {
position: relative;
display: inline-flex;
align-items: center;
padding: 12px 0;
font-size: 14px;
background: transparent;
border: 0;
outline: none;
cursor: pointer;
}
.ant-tabs-tab+.ant-tabs-tab {
margin: 0 0 0 32px;
}
.ant-tabs-ink-bar {
.ant-tabs-tab-active::after {
background: @primary-color;
}
.ant-tabs-tab + .ant-tabs-tab {
margin: 0 0 0 32px;
}
.ant-tabs-ink-bar {
display: none;
}
.im-chat-msg-tab {
gap: 6px;
}
.im-chat-msg-tab-badge {
:deep(.ant-badge-count) {
min-width: 16px;
height: 16px;
line-height: 16px;
padding: 0 4px;
font-size: 12px;
box-shadow: none;
}
}
}
.ant-tabs-nav-scroll {
text-align: center;

View File

@@ -0,0 +1,177 @@
import dayjs, { Dayjs } from 'dayjs';
import { fetchDeptMembers } from '/@/views/system/im/im.api';
import { formatImMessagePreview } from '/@/views/system/im/imMessageUtil';
export const IM_CHAT_BUS_TYPE = 'im_chat';
export const IM_CHAT_NOTICE_TYPE = 'chat';
export interface ImChatSearchParams {
fromUser?: string;
rangeDateKey?: string;
rangeDate?: string[];
noticeType?: string;
starFlag?: string;
}
export interface ImChatNoticeRecord {
id: string;
busType: typeof IM_CHAT_BUS_TYPE;
titile: string;
msgContent: string;
sendTime: string;
createTime: string;
readFlag: string;
noticeType: typeof IM_CHAT_NOTICE_TYPE;
starFlag: string;
imTargetUserId: string;
imTargetUsername?: string;
imAvatar?: string;
imUnreadCount?: number;
}
interface ImContactLike {
id: string;
username?: string;
realname?: string;
avatar?: string;
conversationId?: string;
lastContent?: string;
lastTime?: string;
unreadCount?: number;
}
/** 标星列表不包含 IM全部消息或未指定类型、聊天类型时合并 IM */
export function shouldIncludeImChatInList(params: ImChatSearchParams) {
if (params.starFlag === '1') {
return false;
}
const noticeType = params.noticeType || '';
return !noticeType || noticeType === IM_CHAT_NOTICE_TYPE;
}
export function isImChatOnlyFilter(params: ImChatSearchParams) {
return params.noticeType === IM_CHAT_NOTICE_TYPE;
}
function getRangeBounds(rangeDateKey?: string, rangeDate?: string[]) {
if (!rangeDateKey) {
return null;
}
const now = dayjs();
if (rangeDateKey === 'zdy') {
if (!rangeDate || rangeDate.length < 2 || !rangeDate[0] || !rangeDate[1]) {
return null;
}
return {
start: dayjs(rangeDate[0]).startOf('day'),
end: dayjs(rangeDate[1]).endOf('day'),
};
}
let start: Dayjs = now.startOf('day');
let end: Dayjs = now.endOf('day');
switch (rangeDateKey) {
case 'jt':
break;
case 'zt':
start = now.subtract(1, 'day').startOf('day');
end = now.subtract(1, 'day').endOf('day');
break;
case 'qt':
start = now.subtract(2, 'day').startOf('day');
end = now.subtract(2, 'day').endOf('day');
break;
case 'bz':
start = now.startOf('week').add(1, 'day').startOf('day');
end = now.endOf('day');
break;
case 'sz':
start = now.startOf('week').subtract(6, 'day').startOf('day');
end = now.startOf('week').endOf('day');
break;
case 'by':
start = now.startOf('month');
end = now.endOf('month');
break;
case 'sy':
start = now.subtract(1, 'month').startOf('month');
end = now.subtract(1, 'month').endOf('month');
break;
case '7day':
start = now.subtract(7, 'day').startOf('day');
end = now.endOf('day');
break;
default:
return null;
}
return { start, end };
}
function isInRange(timeText: string | undefined, rangeDateKey?: string, rangeDate?: string[]) {
if (!timeText) {
return false;
}
const time = dayjs(timeText);
if (!time.isValid()) {
return false;
}
const bounds = getRangeBounds(rangeDateKey, rangeDate);
if (!bounds) {
return true;
}
return !time.isBefore(bounds.start) && !time.isAfter(bounds.end);
}
export function mapImContactToNotice(contact: ImContactLike): ImChatNoticeRecord | null {
if (!contact?.lastTime) {
return null;
}
const name = contact.realname || contact.username || '同事';
const preview = formatImMessagePreview(contact.lastContent) || '发来一条新消息';
const timeText = dayjs(contact.lastTime).isValid() ? dayjs(contact.lastTime).format('YYYY-MM-DD HH:mm:ss') : String(contact.lastTime);
return {
id: `im_chat_${contact.id}`,
busType: IM_CHAT_BUS_TYPE,
titile: `${name}${preview}`,
msgContent: preview,
sendTime: timeText,
createTime: timeText,
readFlag: (contact.unreadCount || 0) > 0 ? '0' : '1',
noticeType: IM_CHAT_NOTICE_TYPE,
starFlag: '0',
imTargetUserId: contact.id,
imTargetUsername: contact.username,
imAvatar: contact.avatar,
imUnreadCount: contact.unreadCount || 0,
};
}
export function filterImChatNotices(contacts: ImContactLike[], params: ImChatSearchParams) {
let list = (contacts || [])
.map(mapImContactToNotice)
.filter((item): item is ImChatNoticeRecord => !!item)
.filter((item) => isInRange(item.sendTime, params.rangeDateKey, params.rangeDate));
if (params.fromUser) {
list = list.filter((item) => item.imTargetUsername === params.fromUser || item.imTargetUserId === params.fromUser);
}
list.sort((a, b) => dayjs(b.sendTime).valueOf() - dayjs(a.sendTime).valueOf());
return list;
}
export async function fetchImChatNoticeList(params: ImChatSearchParams) {
const contacts = ((await fetchDeptMembers()) || []) as ImContactLike[];
return filterImChatNotices(contacts, params);
}
export function mergeMessageList(systemList: any[] = [], imList: ImChatNoticeRecord[] = []) {
if (!imList.length) {
return systemList || [];
}
const combined = [...(systemList || []), ...imList];
combined.sort((a, b) => dayjs(b.sendTime || b.createTime).valueOf() - dayjs(a.sendTime || a.createTime).valueOf());
return combined;
}
export function isImChatNotice(item: any) {
return item?.busType === IM_CHAT_BUS_TYPE;
}

View File

@@ -6,6 +6,13 @@ import { useAppStore } from '/@/store/modules/app';
import { useTabs } from '/@/hooks/web/useTabs';
import { useModal } from '/@/components/Modal';
import {useMessage} from "/@/hooks/web/useMessage";
import {
fetchImChatNoticeList,
isImChatOnlyFilter,
mergeMessageList,
shouldIncludeImChatInList,
IM_CHAT_BUS_TYPE,
} from './imChatNoticeAdapter';
/**
* 列表接口
@@ -41,8 +48,12 @@ export function useSysMessage(setLocaleText) {
});
function getSystemNoticeType() {
return searchParams.noticeType === 'chat' ? '' : searchParams.noticeType;
}
function getQueryParams() {
let { fromUser, rangeDateKey, rangeDate, starFlag, noticeType } = searchParams;
let { fromUser, rangeDateKey, rangeDate, starFlag } = searchParams;
let params = {
fromUser,
starFlag,
@@ -51,7 +62,7 @@ export function useSysMessage(setLocaleText) {
endDate: '',
pageNo: pageNo.value,
pageSize,
noticeType
noticeType: getSystemNoticeType(),
};
if (rangeDateKey == 'zdy') {
params.beginDate = rangeDate[0]+' 00:00:00';
@@ -60,29 +71,80 @@ export function useSysMessage(setLocaleText) {
return params;
}
//update-begin---author:cursor ---date:20250528 for【IM聊天-OA】全部消息合并聊天通知、类型筛选支持聊天-----------
async function loadImChatNoticesIfNeeded() {
if (!shouldIncludeImChatInList(searchParams)) {
return [];
}
return fetchImChatNoticeList(searchParams);
}
async function loadImChatOnlyPage() {
const imList = await loadImChatNoticesIfNeeded();
messageList.value = imList;
loadEndStatus.value = true;
pageNo.value = 2;
setLocaleText();
}
function appendSystemPage(data: any[]) {
if (!data || data.length <= 0) {
loadEndStatus.value = true;
return;
}
if (data.length < pageSize) {
loadEndStatus.value = true;
}
pageNo.value = pageNo.value + 1;
messageList.value = [...messageList.value, ...data];
}
//update-end---author:cursor ---date:20250528 for【IM聊天-OA】全部消息合并聊天通知、类型筛选支持聊天-----------
// 数据是否加载完了
const loadEndStatus = ref(false);
//请求数据
async function loadData() {
if(loadEndStatus.value === true){
if (loadEndStatus.value === true) {
return;
}
let params = getQueryParams();
//update-begin---author:cursor ---date:20250528 for【IM聊天-OA】类型筛选「聊天消息」仅展示 IM 通知-----------
if (isImChatOnlyFilter(searchParams)) {
if (pageNo.value > 1) {
loadEndStatus.value = true;
return;
}
await loadImChatOnlyPage();
return;
}
//update-end---author:cursor ---date:20250528 for【IM聊天-OA】类型筛选「聊天消息」仅展示 IM 通知-----------
const params = getQueryParams();
const data = await queryMessageList(params);
console.log('获取结果', data);
if(!data || data.length<=0){
const isFirstPage = pageNo.value === 1;
if (isFirstPage && shouldIncludeImChatInList(searchParams)) {
const imList = await loadImChatNoticesIfNeeded();
if (!data || data.length <= 0) {
messageList.value = imList;
loadEndStatus.value = true;
pageNo.value = 2;
setLocaleText();
return;
}
messageList.value = mergeMessageList(data, imList);
if (data.length < pageSize) {
loadEndStatus.value = true;
}
pageNo.value = 2;
setLocaleText();
return;
}
if (!data || data.length <= 0) {
loadEndStatus.value = true;
setLocaleText();
return;
}
if(data.length<pageSize){
loadEndStatus.value = true;
}
pageNo.value = pageNo.value+1
let temp:any[] = messageList.value;
temp.push(...data);
messageList.value = temp;
appendSystemPage(data);
setLocaleText();
}
@@ -93,6 +155,68 @@ export function useSysMessage(setLocaleText) {
loadEndStatus.value = false;
}
//update-begin---author:cursor ---date:20250528 for【IM聊天-OA】消息弹窗静默刷新避免空状态在「暂无数据」与「还剩余未读」间闪烁-----------
let reloadFetching = false;
let reloadPendingSilent = false;
/** 静默刷新:请求完成后一次性替换,刷新期间不切换空状态文案 */
async function reloadFresh(silent = false) {
if (reloadFetching) {
if (silent) {
reloadPendingSilent = true;
}
return;
}
reloadFetching = true;
try {
if (!silent) {
reset();
await loadData();
return;
}
//update-begin---author:cursor ---date:20250528 for【IM聊天-OA】静默刷新合并聊天通知-----------
if (isImChatOnlyFilter(searchParams)) {
const imList = await loadImChatNoticesIfNeeded();
if (imList.length > 0 || messageList.value.length > 0) {
messageList.value = imList;
}
loadEndStatus.value = true;
pageNo.value = 2;
setLocaleText();
return;
}
//update-end---author:cursor ---date:20250528 for【IM聊天-OA】静默刷新合并聊天通知-----------
// 静默刷新期间保持 loadEndStatus 不变,避免底部「加载更多 / 没有更多了」来回闪
const params = getQueryParams();
params.pageNo = 1;
const data = await queryMessageList(params);
let mergedList = data || [];
if (shouldIncludeImChatInList(searchParams)) {
const imList = await loadImChatNoticesIfNeeded();
mergedList = mergeMessageList(mergedList, imList);
}
if (!mergedList.length) {
if (messageList.value.length > 0) {
messageList.value = [];
}
loadEndStatus.value = true;
pageNo.value = 1;
} else {
messageList.value = mergedList;
loadEndStatus.value = (data || []).length < pageSize;
pageNo.value = 2;
}
setLocaleText();
} finally {
reloadFetching = false;
if (reloadPendingSilent) {
reloadPendingSilent = false;
reloadFresh(true);
}
}
}
//update-end---author:cursor ---date:20250528 for【IM聊天-OA】消息弹窗静默刷新避免空状态在「暂无数据」与「还剩余未读」间闪烁-----------
//标星
async function updateStarMessage(item){
const url = '/sys/sysAnnouncementSend/edit';
@@ -145,6 +269,8 @@ export function useSysMessage(setLocaleText) {
return '督办催办:';
} else if (item.busType == 'eoa_sup_notify') {
return '督办提醒:';
} else if (item.busType == IM_CHAT_BUS_TYPE) {
return '聊天消息:';
} else if (item.msgCategory == '2') {
return '系统消息:';
} else if (item.msgCategory == '1') {
@@ -155,6 +281,9 @@ export function useSysMessage(setLocaleText) {
// QQYUN-4472 来消息了没有提醒--查看详情改为去处理
function getHrefText(item) {
if (item.busType === IM_CHAT_BUS_TYPE) {
return '去聊天';
}
if(item.busType === 'bpm'|| item.busType === 'bpm_task' || item.busType === 'tenant_invite'){
//判断是否是查看详情
if (item.msgAbstract) {
@@ -180,6 +309,7 @@ export function useSysMessage(setLocaleText) {
messageList,
reset,
loadData,
reloadFresh,
loadEndStatus,
searchParams,
updateStarMessage,