IM聊天功能优化
This commit is contained in:
@@ -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】图片消息会话摘要-----------
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
16
jeecgboot-vue3/src/design/im-record-locate.less
Normal file
16
jeecgboot-vue3/src/design/im-record-locate.less
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
442
jeecgboot-vue3/src/views/system/im/ImBizRecordMessageContent.vue
Normal file
442
jeecgboot-vue3/src/views/system/im/ImBizRecordMessageContent.vue
Normal 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>
|
||||
|
||||
@@ -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 for:【IM聊天-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 for:【IM聊天-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 {
|
||||
|
||||
413
jeecgboot-vue3/src/views/system/im/ImChatInput.vue
Normal file
413
jeecgboot-vue3/src/views/system/im/ImChatInput.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
92
jeecgboot-vue3/src/views/system/im/ImPageListPickModal.vue
Normal file
92
jeecgboot-vue3/src/views/system/im/ImPageListPickModal.vue
Normal 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>
|
||||
156
jeecgboot-vue3/src/views/system/im/imBizRecordMessage.ts
Normal file
156
jeecgboot-vue3/src/views/system/im/imBizRecordMessage.ts
Normal 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 };
|
||||
64
jeecgboot-vue3/src/views/system/im/imBizRecordPermission.ts
Normal file
64
jeecgboot-vue3/src/views/system/im/imBizRecordPermission.ts
Normal 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】接收方无菜单权限时不展示业务明细-----------
|
||||
@@ -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 },
|
||||
|
||||
103
jeecgboot-vue3/src/views/system/im/imMessageUtil.ts
Normal file
103
jeecgboot-vue3/src/views/system/im/imMessageUtil.ts
Normal 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 '';
|
||||
}
|
||||
28
jeecgboot-vue3/src/views/system/im/imPageListRegistry.ts
Normal file
28
jeecgboot-vue3/src/views/system/im/imPageListRegistry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
161
jeecgboot-vue3/src/views/system/im/imPageListUtil.ts
Normal file
161
jeecgboot-vue3/src/views/system/im/imPageListUtil.ts
Normal 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();
|
||||
}
|
||||
54
jeecgboot-vue3/src/views/system/im/imPageTitle.ts
Normal file
54
jeecgboot-vue3/src/views/system/im/imPageTitle.ts
Normal 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 时快照明细数据-----------
|
||||
}
|
||||
133
jeecgboot-vue3/src/views/system/im/imRecordLocate.ts
Normal file
133
jeecgboot-vue3/src/views/system/im/imRecordLocate.ts
Normal 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) : '';
|
||||
}
|
||||
95
jeecgboot-vue3/src/views/system/im/imSession.ts
Normal file
95
jeecgboot-vue3/src/views/system/im/imSession.ts
Normal 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';
|
||||
}
|
||||
39
jeecgboot-vue3/src/views/system/im/useImChat.ts
Normal file
39
jeecgboot-vue3/src/views/system/im/useImChat.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user