From a63cd6ad1a487f768be82a23f3676eb49628b8d6 Mon Sep 17 00:00:00 2001 From: geht <2947093423@qq.com> Date: Thu, 28 May 2026 17:08:34 +0800 Subject: [PATCH] =?UTF-8?q?IM=E8=81=8A=E5=A4=A9=E5=8A=9F=E8=83=BD=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../im/service/impl/SysImChatServiceImpl.java | 140 ++++- .../jeecg/modules/im/vo/SysImMessageVO.java | 2 + .../src/design/im-record-locate.less | 16 + jeecgboot-vue3/src/design/index.less | 1 + .../src/hooks/system/useListPage.ts | 176 +++++- jeecgboot-vue3/src/hooks/web/usePage.ts | 12 +- .../header/components/im-chat/index.vue | 69 +-- .../header/components/notify/index.vue | 59 +- .../system/im/ImBizRecordMessageContent.vue | 442 +++++++++++++++ jeecgboot-vue3/src/views/system/im/ImChat.vue | 535 ++++++++++++++++-- .../src/views/system/im/ImChatInput.vue | 413 ++++++++++++++ .../src/views/system/im/ImChatModal.vue | 54 +- .../views/system/im/ImPageListPickModal.vue | 92 +++ .../src/views/system/im/imBizRecordMessage.ts | 156 +++++ .../views/system/im/imBizRecordPermission.ts | 64 +++ jeecgboot-vue3/src/views/system/im/imCache.ts | 128 ++++- .../src/views/system/im/imMessageUtil.ts | 103 ++++ .../src/views/system/im/imPageListRegistry.ts | 28 + .../src/views/system/im/imPageListUtil.ts | 161 ++++++ .../src/views/system/im/imPageTitle.ts | 54 ++ .../src/views/system/im/imRecordLocate.ts | 133 +++++ .../src/views/system/im/imSession.ts | 95 ++++ .../src/views/system/im/useImChat.ts | 39 ++ .../src/views/system/im/useImUnread.ts | 59 +- .../components/SysImChatMessageList.vue | 167 ++++++ .../message/components/SysMessageList.vue | 79 ++- .../message/components/SysMessageModal.vue | 160 ++++-- .../message/components/imChatNoticeAdapter.ts | 177 ++++++ .../message/components/useSysMessage.ts | 154 ++++- 29 files changed, 3565 insertions(+), 203 deletions(-) create mode 100644 jeecgboot-vue3/src/design/im-record-locate.less create mode 100644 jeecgboot-vue3/src/views/system/im/ImBizRecordMessageContent.vue create mode 100644 jeecgboot-vue3/src/views/system/im/ImChatInput.vue create mode 100644 jeecgboot-vue3/src/views/system/im/ImPageListPickModal.vue create mode 100644 jeecgboot-vue3/src/views/system/im/imBizRecordMessage.ts create mode 100644 jeecgboot-vue3/src/views/system/im/imBizRecordPermission.ts create mode 100644 jeecgboot-vue3/src/views/system/im/imMessageUtil.ts create mode 100644 jeecgboot-vue3/src/views/system/im/imPageListRegistry.ts create mode 100644 jeecgboot-vue3/src/views/system/im/imPageListUtil.ts create mode 100644 jeecgboot-vue3/src/views/system/im/imPageTitle.ts create mode 100644 jeecgboot-vue3/src/views/system/im/imRecordLocate.ts create mode 100644 jeecgboot-vue3/src/views/system/im/imSession.ts create mode 100644 jeecgboot-vue3/src/views/system/im/useImChat.ts create mode 100644 jeecgboot-vue3/src/views/system/message/components/SysImChatMessageList.vue create mode 100644 jeecgboot-vue3/src/views/system/message/components/imChatNoticeAdapter.ts diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java index d953730..c9eeba9 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java @@ -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 result = new ArrayList<>(messages.size()); + Map 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 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 members = memberMapper.selectList(new LambdaQueryWrapper() + .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 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】图片消息会话摘要----------- } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImMessageVO.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImMessageVO.java index 6210939..4bfa56e 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImMessageVO.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImMessageVO.java @@ -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") diff --git a/jeecgboot-vue3/src/design/im-record-locate.less b/jeecgboot-vue3/src/design/im-record-locate.less new file mode 100644 index 0000000..7bc8640 --- /dev/null +++ b/jeecgboot-vue3/src/design/im-record-locate.less @@ -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); + } +} diff --git a/jeecgboot-vue3/src/design/index.less b/jeecgboot-vue3/src/design/index.less index 245a6d7..c981009 100644 --- a/jeecgboot-vue3/src/design/index.less +++ b/jeecgboot-vue3/src/design/index.less @@ -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 { diff --git a/jeecgboot-vue3/src/hooks/system/useListPage.ts b/jeecgboot-vue3/src/hooks/system/useListPage.ts index 525ad88..1cfeebb 100644 --- a/jeecgboot-vue3/src/hooks/system/useListPage.ts +++ b/jeecgboot-vue3/src/hooks/system/useListPage.ts @@ -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 | 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() { diff --git a/jeecgboot-vue3/src/hooks/web/usePage.ts b/jeecgboot-vue3/src/hooks/web/usePage.ts index 57a7aeb..3c28bc2 100644 --- a/jeecgboot-vue3/src/hooks/web/usePage.ts +++ b/jeecgboot-vue3/src/hooks/web/usePage.ts @@ -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 & { 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 { 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; diff --git a/jeecgboot-vue3/src/layouts/default/header/components/im-chat/index.vue b/jeecgboot-vue3/src/layouts/default/header/components/im-chat/index.vue index d11531f..5c730dd 100644 --- a/jeecgboot-vue3/src/layouts/default/header/components/im-chat/index.vue +++ b/jeecgboot-vue3/src/layouts/default/header/components/im-chat/index.vue @@ -1,65 +1,47 @@ + + + + + diff --git a/jeecgboot-vue3/src/views/system/im/ImChat.vue b/jeecgboot-vue3/src/views/system/im/ImChat.vue index 4831660..c990296 100644 --- a/jeecgboot-vue3/src/views/system/im/ImChat.vue +++ b/jeecgboot-vue3/src/views/system/im/ImChat.vue @@ -50,7 +50,7 @@ {{ formatTime(item.lastTime) }}
- {{ item.lastContent || '点击开始聊天' }} + {{ formatConvPreview(item.lastContent) }}
@@ -96,42 +96,91 @@ {{ (msg.senderName || '?').slice(0, 1) }} -
+
{{ msg.senderName }}
-
{{ msg.content }}
+ 图片消息 +
{{ renderMessageText(msg.content) }}
+ +
{{ msg.content }}
{{ formatTime(msg.createTime) }}
- -
- 发送 + +
+ + {{ embeddedPageContextTitle }} +
+ +
+ + + 图片预览 + @@ -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 { diff --git a/jeecgboot-vue3/src/views/system/im/ImChatInput.vue b/jeecgboot-vue3/src/views/system/im/ImChatInput.vue new file mode 100644 index 0000000..0cef690 --- /dev/null +++ b/jeecgboot-vue3/src/views/system/im/ImChatInput.vue @@ -0,0 +1,413 @@ + + + + + + + diff --git a/jeecgboot-vue3/src/views/system/im/ImChatModal.vue b/jeecgboot-vue3/src/views/system/im/ImChatModal.vue index 82e88b5..df0ac71 100644 --- a/jeecgboot-vue3/src/views/system/im/ImChatModal.vue +++ b/jeecgboot-vue3/src/views/system/im/ImChatModal.vue @@ -17,29 +17,77 @@ diff --git a/jeecgboot-vue3/src/views/system/message/components/SysMessageList.vue b/jeecgboot-vue3/src/views/system/message/components/SysMessageList.vue index d983f26..b8d3bc0 100644 --- a/jeecgboot-vue3/src/views/system/message/components/SysMessageList.vue +++ b/jeecgboot-vue3/src/views/system/message/components/SysMessageList.vue @@ -2,16 +2,11 @@ @@ -19,7 +14,7 @@ @@ -88,6 +83,17 @@ + + + + @@ -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: `` }}; 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, }; }, }; diff --git a/jeecgboot-vue3/src/views/system/message/components/SysMessageModal.vue b/jeecgboot-vue3/src/views/system/message/components/SysMessageModal.vue index e14c0a7..a494a02 100644 --- a/jeecgboot-vue3/src/views/system/message/components/SysMessageModal.vue +++ b/jeecgboot-vue3/src/views/system/message/components/SysMessageModal.vue @@ -7,7 +7,6 @@ wrapClassName="sys-msg-modal" :width="800" :footer="null" - destroyOnClose >