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.im.vo.SysImMessageVO;
|
||||||
import org.jeecg.modules.message.websocket.WebSocket;
|
import org.jeecg.modules.message.websocket.WebSocket;
|
||||||
import org.jeecg.modules.system.entity.SysDepart;
|
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.SysUser;
|
||||||
import org.jeecg.modules.system.entity.SysUserDepart;
|
import org.jeecg.modules.system.entity.SysUserDepart;
|
||||||
import org.jeecg.modules.system.mapper.SysDepartMapper;
|
import org.jeecg.modules.system.mapper.SysDepartMapper;
|
||||||
import org.jeecg.modules.system.mapper.SysUserDepartMapper;
|
import org.jeecg.modules.system.mapper.SysUserDepartMapper;
|
||||||
import org.jeecg.modules.system.mapper.SysUserMapper;
|
import org.jeecg.modules.system.mapper.SysUserMapper;
|
||||||
import org.jeecg.modules.system.mapper.SysUserTenantMapper;
|
import org.jeecg.modules.system.mapper.SysUserTenantMapper;
|
||||||
|
import org.jeecg.modules.system.service.ISysPermissionService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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 CONV_TYPE_SINGLE = "single";
|
||||||
private static final String MSG_TYPE_TEXT = "text";
|
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
|
@Autowired
|
||||||
private SysImConversationMapper conversationMapper;
|
private SysImConversationMapper conversationMapper;
|
||||||
@Autowired
|
@Autowired
|
||||||
@@ -169,13 +178,16 @@ public class SysImChatServiceImpl implements ISysImChatService {
|
|||||||
messageMapper.insert(message);
|
messageMapper.insert(message);
|
||||||
|
|
||||||
SysImConversation conversation = conversationMapper.selectById(dto.getConversationId());
|
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.setLastTime(now);
|
||||||
conversation.setUpdateTime(now);
|
conversation.setUpdateTime(now);
|
||||||
conversationMapper.updateById(conversation);
|
conversationMapper.updateById(conversation);
|
||||||
|
|
||||||
memberMapper.incrementUnreadExceptSender(dto.getConversationId(), userId);
|
memberMapper.incrementUnreadExceptSender(dto.getConversationId(), userId);
|
||||||
SysImMessageVO messageVo = toMessageVo(message, userId);
|
SysImMessageVO messageVo = toMessageVo(message, userId);
|
||||||
|
fillBizRecordReceiverPermission(messageVo, message, userId, new HashMap<>(4));
|
||||||
pushChatMessage(dto.getConversationId(), userId, messageVo);
|
pushChatMessage(dto.getConversationId(), userId, messageVo);
|
||||||
return messageVo;
|
return messageVo;
|
||||||
}
|
}
|
||||||
@@ -376,8 +388,11 @@ public class SysImChatServiceImpl implements ISysImChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
List<SysImMessageVO> result = new ArrayList<>(messages.size());
|
List<SysImMessageVO> result = new ArrayList<>(messages.size());
|
||||||
|
Map<String, Boolean> receiverPermissionCache = new HashMap<>(16);
|
||||||
for (SysImMessage message : messages) {
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -401,6 +416,97 @@ public class SysImChatServiceImpl implements ISysImChatService {
|
|||||||
}
|
}
|
||||||
return vo;
|
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】消息列表批量填充发送人-----------
|
//update-end---author:cursor ---date:20260528 for:【IM聊天-OA】消息列表批量填充发送人-----------
|
||||||
|
|
||||||
private void pushChatMessage(String conversationId, String senderId, SysImMessageVO messageVo) {
|
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);
|
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;
|
private String msgType;
|
||||||
@Schema(description = "是否本人发送")
|
@Schema(description = "是否本人发送")
|
||||||
private Boolean mine;
|
private Boolean mine;
|
||||||
|
@Schema(description = "业务明细接收方是否有对应功能权限(仅发送方可见)")
|
||||||
|
private Boolean receiverHasBizPagePermission;
|
||||||
@Schema(description = "发送时间")
|
@Schema(description = "发送时间")
|
||||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
@DateTimeFormat(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 'public.less';
|
||||||
@import 'ant/index.less';
|
@import 'ant/index.less';
|
||||||
@import './theme.less';
|
@import './theme.less';
|
||||||
|
@import './im-record-locate.less';
|
||||||
@import './entry.css';
|
@import './entry.css';
|
||||||
|
|
||||||
input:-webkit-autofill {
|
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 { merge } from 'lodash-es';
|
||||||
import { DynamicProps } from '/#/utils';
|
import { DynamicProps } from '/#/utils';
|
||||||
import { BasicTableProps, TableActionType, useTable } from '/@/components/Table';
|
import { BasicTableProps, TableActionType, useTable } from '/@/components/Table';
|
||||||
@@ -9,6 +10,16 @@ import { useMethods } from '/@/hooks/system/useMethods';
|
|||||||
import { useDesign } from '/@/hooks/web/useDesign';
|
import { useDesign } from '/@/hooks/web/useDesign';
|
||||||
import { filterObj } from '/@/utils/common/compUtils';
|
import { filterObj } from '/@/utils/common/compUtils';
|
||||||
import { isFunction } from '@/utils/is';
|
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();
|
const { handleExportXls, handleImportXls } = useMethods();
|
||||||
|
|
||||||
// 定义 useListPage 方法所需参数
|
// 定义 useListPage 方法所需参数
|
||||||
@@ -59,7 +70,168 @@ export function useListPage(options: ListPageOptions) {
|
|||||||
|
|
||||||
const tableContext = useListTable(options.tableProps);
|
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
|
// 导出 excel
|
||||||
async function onExportXls() {
|
async function onExportXls() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useRouter } from 'vue-router';
|
|||||||
import { REDIRECT_NAME } from '/@/router/constant';
|
import { REDIRECT_NAME } from '/@/router/constant';
|
||||||
import { useUserStore } from '/@/store/modules/user';
|
import { useUserStore } from '/@/store/modules/user';
|
||||||
import { useMultipleTabStore } from '/@/store/modules/multipleTab';
|
import { useMultipleTabStore } from '/@/store/modules/multipleTab';
|
||||||
|
import { clearImRecordLocateState, stripImRecordQuery } from '/@/views/system/im/imRecordLocate';
|
||||||
|
|
||||||
export type RouteLocationRawEx = Omit<RouteLocationRaw, 'path'> & { path: PageEnum };
|
export type RouteLocationRawEx = Omit<RouteLocationRaw, 'path'> & { path: PageEnum };
|
||||||
|
|
||||||
@@ -43,10 +44,17 @@ export function useGo(_router?: Router) {
|
|||||||
* @description: redo current page
|
* @description: redo current page
|
||||||
*/
|
*/
|
||||||
export const useRedo = (_router?: Router, otherQuery?: Recordable) => {
|
export const useRedo = (_router?: Router, otherQuery?: Recordable) => {
|
||||||
const { push, currentRoute } = _router || useRouter();
|
const router = _router || useRouter();
|
||||||
const { query, params = {}, name, fullPath } = unref(currentRoute.value);
|
const { push, currentRoute, resolve: resolveRoute } = router;
|
||||||
function redo(): Promise<boolean> {
|
function redo(): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
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) {
|
if (name === REDIRECT_NAME) {
|
||||||
resolve(false);
|
resolve(false);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,65 +1,47 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="prefixCls">
|
<div :class="[prefixCls, { 'is-disabled': imPageActive }]" @click="openChat">
|
||||||
<Badge :count="totalUnread" :overflowCount="99" :offset="[-4, 18]" :numberStyle="numberStyle" @click="openChat">
|
|
||||||
<MessageOutlined />
|
<MessageOutlined />
|
||||||
</Badge>
|
|
||||||
<ImChatModal @register="registerModal" />
|
<ImChatModal @register="registerModal" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, onMounted, onUnmounted } from 'vue';
|
import { defineComponent, onMounted } from 'vue';
|
||||||
import { Badge } from 'ant-design-vue';
|
|
||||||
import { MessageOutlined } from '@ant-design/icons-vue';
|
import { MessageOutlined } from '@ant-design/icons-vue';
|
||||||
import { useModal } from '/@/components/Modal';
|
import { useModal } from '/@/components/Modal';
|
||||||
import { useDesign } from '/@/hooks/web/useDesign';
|
import { useDesign } from '/@/hooks/web/useDesign';
|
||||||
import { ensureWebSocketConnected, onWebSocket, offWebSocket } from '/@/hooks/web/useWebSocket';
|
|
||||||
import ImChatModal from '/@/views/system/im/ImChatModal.vue';
|
import ImChatModal from '/@/views/system/im/ImChatModal.vue';
|
||||||
import { prefetchImChatData, handleImChatSocket } from '/@/views/system/im/imCache';
|
import { prefetchImChatData } from '/@/views/system/im/imCache';
|
||||||
import { useImUnread } from '/@/views/system/im/useImUnread';
|
import { useImChat } from '/@/views/system/im/useImChat';
|
||||||
|
import { refreshImUnread } from '/@/views/system/im/useImUnread';
|
||||||
|
import { useImChatPageActive } from '/@/views/system/im/imSession';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'HeaderImChat',
|
name: 'HeaderImChat',
|
||||||
components: {
|
components: {
|
||||||
Badge,
|
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
ImChatModal,
|
ImChatModal,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { prefixCls } = useDesign('header-im-chat');
|
const { prefixCls } = useDesign('header-im-chat');
|
||||||
const { totalUnread } = useImUnread();
|
|
||||||
const [registerModal, { openModal }] = useModal();
|
const [registerModal, { openModal }] = useModal();
|
||||||
|
const imPageActive = useImChatPageActive();
|
||||||
const numberStyle = {
|
const { openChatModal } = useImChat();
|
||||||
fontSize: '12px',
|
|
||||||
height: '16px',
|
|
||||||
minWidth: '16px',
|
|
||||||
lineHeight: '16px',
|
|
||||||
padding: '0 4px',
|
|
||||||
};
|
|
||||||
|
|
||||||
function openChat() {
|
function openChat() {
|
||||||
openModal(true, {});
|
//update-begin---author:xsl ---date:20260528 for:【IM聊天-OA】头部打开 IM 时快照当前功能页名称-----------
|
||||||
}
|
openChatModal(openModal);
|
||||||
|
//update-end---author:xsl ---date:20260528 for:【IM聊天-OA】头部打开 IM 时快照当前功能页名称-----------
|
||||||
function onImSocket(data: Record<string, any>) {
|
|
||||||
handleImChatSocket(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
ensureWebSocketConnected();
|
|
||||||
prefetchImChatData();
|
prefetchImChatData();
|
||||||
onWebSocket(onImSocket);
|
refreshImUnread(true);
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
offWebSocket(onImSocket);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prefixCls,
|
prefixCls,
|
||||||
totalUnread,
|
imPageActive,
|
||||||
numberStyle,
|
|
||||||
openChat,
|
openChat,
|
||||||
registerModal,
|
registerModal,
|
||||||
};
|
};
|
||||||
@@ -73,22 +55,17 @@
|
|||||||
.@{prefix-cls} {
|
.@{prefix-cls} {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
|
|
||||||
.ant-badge {
|
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
display: inline-flex;
|
||||||
.ant-badge-count {
|
align-items: center;
|
||||||
@badget-size: 16px;
|
|
||||||
width: @badget-size;
|
|
||||||
height: @badget-size;
|
|
||||||
min-width: @badget-size;
|
|
||||||
line-height: @badget-size;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 0.9em;
|
width: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="prefixCls">
|
<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 />
|
<BellOutlined />
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<DynamicNotice ref="dynamicNoticeRef" v-bind="dynamicNoticeProps" />
|
<DynamicNotice ref="dynamicNoticeRef" v-bind="dynamicNoticeProps" />
|
||||||
<DetailModal @register="registerDetail" />
|
<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>
|
<ChangePasswordModal @register="changePwdModal"></ChangePasswordModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<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 { Popover, Tabs, Badge } from 'ant-design-vue';
|
||||||
import { BellOutlined } from '@ant-design/icons-vue';
|
import { BellOutlined } from '@ant-design/icons-vue';
|
||||||
// import { tabListData } from './data';
|
// import { tabListData } from './data';
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
import { useDesign } from '/@/hooks/web/useDesign';
|
import { useDesign } from '/@/hooks/web/useDesign';
|
||||||
import { useGlobSetting } from '/@/hooks/setting';
|
import { useGlobSetting } from '/@/hooks/setting';
|
||||||
import { useUserStore } from '/@/store/modules/user';
|
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 { readAllMsg } from '/@/views/monitor/mynews/mynews.api';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
@@ -33,6 +33,8 @@
|
|||||||
import ChangePasswordModal from './ChangePasswordModal.vue'
|
import ChangePasswordModal from './ChangePasswordModal.vue'
|
||||||
import { ElectronEnum } from '/@/enums/jeecgEnum';
|
import { ElectronEnum } from '/@/enums/jeecgEnum';
|
||||||
import { defHttp } from "@/utils/http/axios";
|
import { defHttp } from "@/utils/http/axios";
|
||||||
|
import { handleImChatSocket } from '/@/views/system/im/imCache';
|
||||||
|
import { useImUnread } from '/@/views/system/im/useImUnread';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
@@ -83,11 +85,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const popoverVisible = ref<boolean>(false);
|
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(() => {
|
onMounted(() => {
|
||||||
initWebSocket();
|
initWebSocket();
|
||||||
|
refreshImUnreadCount(true);
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
offWebSocket(onWebSocketMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageCount = ref<number>(0)
|
|
||||||
function mapAnnouncement(item) {
|
function mapAnnouncement(item) {
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
@@ -111,7 +122,7 @@
|
|||||||
let msgCount = await getUnreadMessageCount();
|
let msgCount = await getUnreadMessageCount();
|
||||||
// 代码逻辑说明: 【QQYUN-12162】OA项目改造,系统重消息拆分,目前消息都在一起 需按分类进行拆分---
|
// 代码逻辑说明: 【QQYUN-12162】OA项目改造,系统重消息拆分,目前消息都在一起 需按分类进行拆分---
|
||||||
unReadNum.value = msgCount;
|
unReadNum.value = msgCount;
|
||||||
messageCount.value = msgCount.count?msgCount.count:0;
|
systemMessageCount.value = msgCount.count ? msgCount.count : 0;
|
||||||
// 代码逻辑说明: 【JHHB-13】桌面应用消息通知
|
// 代码逻辑说明: 【JHHB-13】桌面应用消息通知
|
||||||
if (glob.isElectronPlatform) {
|
if (glob.isElectronPlatform) {
|
||||||
window[ElectronEnum.ELECTRON_API].sendNotifyFlash(messageCount.value);
|
window[ElectronEnum.ELECTRON_API].sendNotifyFlash(messageCount.value);
|
||||||
@@ -122,7 +133,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
loadData();
|
||||||
|
window.setTimeout(() => loadData(), 800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onNoticeClick(record) {
|
function onNoticeClick(record) {
|
||||||
try {
|
try {
|
||||||
@@ -154,25 +179,9 @@
|
|||||||
onWebSocket(onWebSocketMessage);
|
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) {
|
function notification(data) {
|
||||||
if (glob.isElectronPlatform && (data.noticeType || data.cmd == 'email')) {
|
if (glob.isElectronPlatform && (data.noticeType || data.cmd == 'email')) {
|
||||||
// 流程、文件、日程、系统、会议
|
|
||||||
// flow、file、plan、system、meeting
|
|
||||||
let title = '';
|
let title = '';
|
||||||
let msgTxt = '';
|
let msgTxt = '';
|
||||||
let path = '';
|
let path = '';
|
||||||
@@ -199,6 +208,7 @@
|
|||||||
window[ElectronEnum.ELECTRON_API].sendNotification(`有新的${title}消息`, msgTxt, path);
|
window[ElectronEnum.ELECTRON_API].sendNotification(`有新的${title}消息`, msgTxt, path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清空消息
|
// 清空消息
|
||||||
function onEmptyNotify() {
|
function onEmptyNotify() {
|
||||||
popoverVisible.value = false;
|
popoverVisible.value = false;
|
||||||
@@ -208,6 +218,7 @@
|
|||||||
try {
|
try {
|
||||||
await editCementSend(id);
|
await editCementSend(id);
|
||||||
await loadData();
|
await loadData();
|
||||||
|
refreshImUnreadCount(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(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>
|
<span class="conv-time">{{ formatTime(item.lastTime) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="conv-bottom">
|
<div class="conv-bottom">
|
||||||
<span class="conv-preview">{{ item.lastContent || '点击开始聊天' }}</span>
|
<span class="conv-preview">{{ formatConvPreview(item.lastContent) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,42 +96,91 @@
|
|||||||
<a-avatar :size="32" :src="getAvatarUrl(msg.senderAvatar)">
|
<a-avatar :size="32" :src="getAvatarUrl(msg.senderAvatar)">
|
||||||
{{ (msg.senderName || '?').slice(0, 1) }}
|
{{ (msg.senderName || '?').slice(0, 1) }}
|
||||||
</a-avatar>
|
</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-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 class="message-time">{{ formatTime(msg.createTime) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-input">
|
<div class="message-input">
|
||||||
<a-textarea
|
<!--update-begin---author:xsl ---date:20260528 for:【IM聊天-OA】弹窗输入框上方展示背后功能页名称------------->
|
||||||
v-model:value="draft"
|
<div
|
||||||
:rows="3"
|
v-if="embeddedPageContextTitle"
|
||||||
placeholder="输入消息,Enter发送,Shift+Enter换行"
|
:class="['im-page-context-bubble', { 'is-clickable': embeddedPageContextClickable }]"
|
||||||
@pressEnter="handlePressEnter"
|
:title="embeddedPageContextBubbleTitle"
|
||||||
/>
|
@click="handlePageContextBubbleClick"
|
||||||
<div class="input-actions">
|
>
|
||||||
<a-button type="primary" :loading="sending" @click="handleSend">发送</a-button>
|
<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>
|
</div>
|
||||||
|
<!--update-end---author:xsl ---date:20260528 for:【IM聊天-OA】弹窗输入框上方展示背后功能页名称------------->
|
||||||
|
<ImChatInput
|
||||||
|
v-model="draft"
|
||||||
|
:disabled="!activeConversationId"
|
||||||
|
:sending="sending"
|
||||||
|
@send="handleSend"
|
||||||
|
@image-uploaded="sendImageMessage"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<a-empty v-else class="empty-chat" description="请从左侧选择本部门同事开始聊天" />
|
<a-empty v-else class="empty-chat" description="请从左侧选择本部门同事开始聊天" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<ImChatSettingsModal @register="registerSettingsModal" @saved="onChatSettingsSaved" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 dayjs from 'dayjs';
|
||||||
import { ensureWebSocketConnected, onWebSocket, offWebSocket } from '/@/hooks/web/useWebSocket';
|
import { ensureWebSocketConnected, onWebSocket, offWebSocket } from '/@/hooks/web/useWebSocket';
|
||||||
import { useUserStore } from '/@/store/modules/user';
|
import { useUserStore } from '/@/store/modules/user';
|
||||||
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
|
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
|
||||||
import { fetchDeptMembers, openConversation, fetchMessages, sendMessage, markRead } from './im.api';
|
import { fetchDeptMembers, openConversation, fetchMessages, sendMessage, markRead } from './im.api';
|
||||||
import { getImDefaultHistoryDays, getImDefaultStartTime, isWithinDefaultHistoryRange } from './imSettings';
|
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 ImChatSettingsModal from './ImChatSettingsModal.vue';
|
||||||
|
import ImPageListPickModal from './ImPageListPickModal.vue';
|
||||||
|
import ImBizRecordMessageContent from './ImBizRecordMessageContent.vue';
|
||||||
import { useModal } from '/@/components/Modal';
|
import { useModal } from '/@/components/Modal';
|
||||||
|
import { useMessage } from '/@/hooks/web/useMessage';
|
||||||
|
import { buildImBizRecordPayload, parseImBizRecordPayload, serializeImBizRecordPayload } from './imBizRecordMessage';
|
||||||
import { syncImUnreadFromMembers } from './useImUnread';
|
import { syncImUnreadFromMembers } from './useImUnread';
|
||||||
import {
|
import {
|
||||||
type ImMemberItem,
|
type ImMemberItem,
|
||||||
@@ -146,7 +195,18 @@
|
|||||||
patchCachedMember,
|
patchCachedMember,
|
||||||
prefetchImChatData,
|
prefetchImChatData,
|
||||||
setImActiveConversationId,
|
setImActiveConversationId,
|
||||||
|
setImActiveTargetUserId,
|
||||||
|
clearImActiveSession,
|
||||||
|
isImChatWindowOpen,
|
||||||
|
onImMessagesUpdated,
|
||||||
|
onImChatWindowOpenChange,
|
||||||
} from './imCache';
|
} from './imCache';
|
||||||
|
import {
|
||||||
|
setImChatPageActive,
|
||||||
|
isImChatPageActive,
|
||||||
|
onImOpenTargetRequest,
|
||||||
|
useImPageContext,
|
||||||
|
} from './imSession';
|
||||||
|
|
||||||
defineOptions({ name: 'ImChat' });
|
defineOptions({ name: 'ImChat' });
|
||||||
|
|
||||||
@@ -165,7 +225,71 @@
|
|||||||
interface MessageItem extends ImMessageItem {}
|
interface MessageItem extends ImMessageItem {}
|
||||||
|
|
||||||
const userStore = useUserStore();
|
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();
|
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 defaultHistoryDays = ref(getImDefaultHistoryDays());
|
||||||
const currentUserId = computed(() => userStore.getUserInfo?.id || '');
|
const currentUserId = computed(() => userStore.getUserInfo?.id || '');
|
||||||
const deptLabel = computed(() => {
|
const deptLabel = computed(() => {
|
||||||
@@ -176,6 +300,23 @@
|
|||||||
const memberLoading = ref(false);
|
const memberLoading = ref(false);
|
||||||
const msgLoading = ref(false);
|
const msgLoading = ref(false);
|
||||||
const sending = 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 memberKeyword = ref('');
|
||||||
const draft = ref('');
|
const draft = ref('');
|
||||||
const deptMembers = ref<DeptMemberItem[]>([]);
|
const deptMembers = ref<DeptMemberItem[]>([]);
|
||||||
@@ -261,6 +402,19 @@
|
|||||||
return avatar ? getFileAccessHttpUrl(avatar) : '';
|
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) {
|
function formatTime(value?: string) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '';
|
return '';
|
||||||
@@ -280,6 +434,27 @@
|
|||||||
return records.filter((item) => isWithinDefaultHistoryRange(item.createTime));
|
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 }) {
|
function handleSettingsMenuClick({ key }: { key: string }) {
|
||||||
if (key === 'chatSettings') {
|
if (key === 'chatSettings') {
|
||||||
openSettingsModal(true, {});
|
openSettingsModal(true, {});
|
||||||
@@ -322,6 +497,7 @@
|
|||||||
}
|
}
|
||||||
syncImUnreadFromMembers(deptMembers.value);
|
syncImUnreadFromMembers(deptMembers.value);
|
||||||
syncActiveMember();
|
syncActiveMember();
|
||||||
|
applyActiveSessionReadState();
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent && !usedCache) {
|
if (!silent && !usedCache) {
|
||||||
memberLoading.value = false;
|
memberLoading.value = false;
|
||||||
@@ -373,13 +549,24 @@
|
|||||||
}
|
}
|
||||||
const found = deptMembers.value.find((item) => item.id === activeTargetUserId.value);
|
const found = deptMembers.value.find((item) => item.id === activeTargetUserId.value);
|
||||||
if (found) {
|
if (found) {
|
||||||
activeMember.value = found;
|
const normalized = { ...found, unreadCount: 0 };
|
||||||
|
activeMember.value = normalized;
|
||||||
if (found.conversationId) {
|
if (found.conversationId) {
|
||||||
activeConversationId.value = 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[]) {
|
function updateHasMore(latestBatch: MessageItem[]) {
|
||||||
if (latestBatch.length >= pageSize) {
|
if (latestBatch.length >= pageSize) {
|
||||||
@@ -399,7 +586,7 @@
|
|||||||
hasMore.value = dayjs(lastTime).isBefore(dayjs(oldestLoaded));
|
hasMore.value = dayjs(lastTime).isBefore(dayjs(oldestLoaded));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMessages(reset = true) {
|
async function loadMessages(reset = true, options?: { forceRefresh?: boolean }) {
|
||||||
if (!activeConversationId.value) {
|
if (!activeConversationId.value) {
|
||||||
messageList.value = [];
|
messageList.value = [];
|
||||||
return;
|
return;
|
||||||
@@ -407,6 +594,8 @@
|
|||||||
const conversationId = activeConversationId.value;
|
const conversationId = activeConversationId.value;
|
||||||
const requestSeq = ++loadMessagesSeq;
|
const requestSeq = ++loadMessagesSeq;
|
||||||
let displayedFromCache = false;
|
let displayedFromCache = false;
|
||||||
|
const forceRefresh = !!options?.forceRefresh;
|
||||||
|
const needFetch = shouldFetchLatestMessages(conversationId, forceRefresh);
|
||||||
|
|
||||||
if (reset) {
|
if (reset) {
|
||||||
const cached = getCachedMessages(conversationId);
|
const cached = getCachedMessages(conversationId);
|
||||||
@@ -420,7 +609,7 @@
|
|||||||
if (activeTargetUserId.value) {
|
if (activeTargetUserId.value) {
|
||||||
patchDeptMember(activeTargetUserId.value, { unreadCount: 0 }, { moveToTop: false });
|
patchDeptMember(activeTargetUserId.value, { unreadCount: 0 }, { moveToTop: false });
|
||||||
}
|
}
|
||||||
if (!isMessagesCacheStale(conversationId)) {
|
if (!needFetch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
if (activeTargetUserId.value === item.id && activeConversationId.value) {
|
||||||
|
activeMember.value = { ...item, unreadCount: 0 };
|
||||||
|
applyActiveSessionReadState();
|
||||||
|
if (forceRefreshMessages || hasNewerMessagesThanCache(activeConversationId.value)) {
|
||||||
|
await loadMessages(true, { forceRefresh: true });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activeTargetUserId.value = item.id;
|
activeTargetUserId.value = item.id;
|
||||||
activeMember.value = item;
|
setImActiveTargetUserId(item.id);
|
||||||
|
activeMember.value = { ...item, unreadCount: 0 };
|
||||||
|
|
||||||
if (item.conversationId) {
|
if (item.conversationId) {
|
||||||
activeConversationId.value = item.conversationId;
|
activeConversationId.value = item.conversationId;
|
||||||
setImActiveConversationId(item.conversationId);
|
setImActiveConversationId(item.conversationId);
|
||||||
await loadMessages(true);
|
await loadMessages(true, { forceRefresh: forceRefreshMessages });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,7 +730,7 @@
|
|||||||
lastTime: conv.lastTime,
|
lastTime: conv.lastTime,
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
}, { moveToTop: false });
|
}, { moveToTop: false });
|
||||||
await loadMessages(true);
|
await loadMessages(true, { forceRefresh: forceRefreshMessages });
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
@@ -544,22 +740,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSend() {
|
async function sendImageMessage(imagePath: string) {
|
||||||
const content = draft.value.trim();
|
if (!activeConversationId.value) {
|
||||||
if (!content || !activeConversationId.value) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sending.value = true;
|
sending.value = true;
|
||||||
try {
|
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);
|
messageList.value.push(msg);
|
||||||
appendCachedMessage(activeConversationId.value, msg);
|
appendCachedMessage(activeConversationId.value, msg);
|
||||||
draft.value = '';
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
patchDeptMember(activeTargetUserId.value, {
|
patchDeptMember(activeTargetUserId.value, {
|
||||||
conversationId: activeConversationId.value,
|
conversationId: activeConversationId.value,
|
||||||
lastContent: msg.content,
|
lastContent: formatImMessagePreview(msg.content, msg.msgType),
|
||||||
lastTime: msg.createTime,
|
lastTime: msg.createTime,
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
});
|
});
|
||||||
@@ -568,21 +766,105 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePressEnter(e: KeyboardEvent) {
|
async function handleSend() {
|
||||||
if (e.shiftKey) {
|
const content = draft.value.trim();
|
||||||
|
if (!content || !activeConversationId.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
await sendTextMessage(content);
|
||||||
handleSend();
|
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>) {
|
function onChatSocket(data: Record<string, any>) {
|
||||||
if (data.cmd !== 'chat') {
|
if (data.cmd !== 'chat') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const conversationId = data.conversationId as string;
|
const conversationId = data.conversationId as string;
|
||||||
const senderId = data.senderId 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) {
|
if (isActiveConversation) {
|
||||||
const exists = messageList.value.some((item) => item.id === data.messageId);
|
const exists = messageList.value.some((item) => item.id === data.messageId);
|
||||||
@@ -605,7 +887,7 @@
|
|||||||
if (activeTargetUserId.value) {
|
if (activeTargetUserId.value) {
|
||||||
patchDeptMember(activeTargetUserId.value, {
|
patchDeptMember(activeTargetUserId.value, {
|
||||||
conversationId,
|
conversationId,
|
||||||
lastContent: data.content,
|
lastContent: formatImMessagePreview(data.content, data.msgType),
|
||||||
lastTime: data.createTime,
|
lastTime: data.createTime,
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
});
|
});
|
||||||
@@ -618,7 +900,7 @@
|
|||||||
senderId,
|
senderId,
|
||||||
{
|
{
|
||||||
conversationId,
|
conversationId,
|
||||||
lastContent: data.content,
|
lastContent: formatImMessagePreview(data.content, data.msgType),
|
||||||
lastTime: data.createTime,
|
lastTime: data.createTime,
|
||||||
},
|
},
|
||||||
{ unreadIncrement: 1 },
|
{ unreadIncrement: 1 },
|
||||||
@@ -631,11 +913,51 @@
|
|||||||
onWebSocket(onChatSocket);
|
onWebSocket(onChatSocket);
|
||||||
loadDeptMembers();
|
loadDeptMembers();
|
||||||
prefetchImChatData();
|
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() {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const cached = getCachedMessages(activeConversationId.value);
|
const cached = getCachedMessages(activeConversationId.value);
|
||||||
@@ -643,16 +965,61 @@
|
|||||||
messageList.value = filterDefaultRangeMessages(cached.records);
|
messageList.value = filterDefaultRangeMessages(cached.records);
|
||||||
updateHasMore(messageList.value);
|
updateHasMore(messageList.value);
|
||||||
nextTick(() => scrollToBottom());
|
nextTick(() => scrollToBottom());
|
||||||
|
applyActiveSessionReadState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadMessages(true);
|
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(() => {
|
onUnmounted(() => {
|
||||||
offWebSocket(onChatSocket);
|
offWebSocket(onChatSocket);
|
||||||
setImActiveConversationId('');
|
unsubscribeMessagesUpdated?.();
|
||||||
|
unsubscribeChatWindowOpen?.();
|
||||||
|
unsubscribeOpenTarget?.();
|
||||||
|
if (!props.embedded) {
|
||||||
|
setImChatPageActive(false);
|
||||||
|
}
|
||||||
|
if (isThisInstanceActive()) {
|
||||||
|
clearImActiveSession();
|
||||||
|
}
|
||||||
stopResize();
|
stopResize();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -978,13 +1345,101 @@
|
|||||||
|
|
||||||
.message-input {
|
.message-input {
|
||||||
border-top: 1px solid #f0f0f0;
|
border-top: 1px solid #f0f0f0;
|
||||||
padding: 12px 16px 16px;
|
padding: 10px 12px 12px;
|
||||||
|
background: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-actions {
|
//update-begin---author:xsl ---date:20260528 for:【IM聊天-OA】弹窗输入框上方展示背后功能页名称-----------
|
||||||
display: flex;
|
.im-page-context-bubble {
|
||||||
justify-content: flex-end;
|
display: inline-flex;
|
||||||
margin-top: 8px;
|
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 {
|
.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>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, nextTick } from 'vue';
|
import { ref, nextTick, onMounted, onUnmounted } from 'vue';
|
||||||
import { BasicModal, useModalInner } from '/@/components/Modal';
|
import { BasicModal, useModalInner } from '/@/components/Modal';
|
||||||
import ImChat from './ImChat.vue';
|
import ImChat from './ImChat.vue';
|
||||||
|
import { setImChatWindowOpen } from './imCache';
|
||||||
|
import { canOpenImChatModal, onImChatModalCloseRequest, openImChat, setImPageContext } from './imSession';
|
||||||
|
import { refreshImUnread } from './useImUnread';
|
||||||
|
|
||||||
defineOptions({ name: 'ImChatModal' });
|
defineOptions({ name: 'ImChatModal' });
|
||||||
|
|
||||||
const imChatRef = ref<InstanceType<typeof ImChat>>();
|
const imChatRef = ref<InstanceType<typeof ImChat>>();
|
||||||
|
const pendingTargetUserId = ref('');
|
||||||
|
|
||||||
function restoreChatSession() {
|
function restoreChatSession() {
|
||||||
nextTick(() => {
|
nextTick(async () => {
|
||||||
|
const targetUserId = pendingTargetUserId.value;
|
||||||
|
if (targetUserId) {
|
||||||
|
pendingTargetUserId.value = '';
|
||||||
|
await imChatRef.value?.openTargetChat?.(targetUserId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
imChatRef.value?.restoreSessionIfNeeded?.();
|
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();
|
restoreChatSession();
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleOpenChange(open: boolean) {
|
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) {
|
if (open) {
|
||||||
restoreChatSession();
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="less">
|
<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 { useUserStoreWithOut } from '/@/store/modules/user';
|
||||||
import { fetchDeptMembers, fetchMessages } from './im.api';
|
import { fetchDeptMembers, fetchMessages } from './im.api';
|
||||||
import { getImDefaultStartTime } from './imSettings';
|
import { getImDefaultStartTime } from './imSettings';
|
||||||
|
import { formatImMessagePreview } from './imMessageUtil';
|
||||||
import { syncImUnreadFromMembers } from './useImUnread';
|
import { syncImUnreadFromMembers } from './useImUnread';
|
||||||
|
import { isImChatPageActive } from './imSession';
|
||||||
|
|
||||||
export interface ImMemberItem {
|
export interface ImMemberItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +29,8 @@ export interface ImMessageItem {
|
|||||||
msgType?: string;
|
msgType?: string;
|
||||||
mine?: boolean;
|
mine?: boolean;
|
||||||
createTime?: string;
|
createTime?: string;
|
||||||
|
/** 业务明细:接收方是否有对应功能页权限(仅发送方消息返回) */
|
||||||
|
receiverHasBizPagePermission?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImMessageCacheEntry {
|
export interface ImMessageCacheEntry {
|
||||||
@@ -52,6 +56,48 @@ const sessionCache = createSessionStorage({ timeout: 60 * 60 });
|
|||||||
let memorySnapshot: ImCacheSnapshot | null = null;
|
let memorySnapshot: ImCacheSnapshot | null = null;
|
||||||
let prefetchPromise: Promise<void> | null = null;
|
let prefetchPromise: Promise<void> | null = null;
|
||||||
let activeConversationId = '';
|
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 {
|
function getCacheScopeKey(): string | null {
|
||||||
const userStore = useUserStoreWithOut();
|
const userStore = useUserStoreWithOut();
|
||||||
@@ -94,11 +140,53 @@ export function setImActiveConversationId(conversationId: string) {
|
|||||||
activeConversationId = conversationId || '';
|
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() {
|
export function clearImCache() {
|
||||||
const scope = getCacheScopeKey();
|
const scope = getCacheScopeKey();
|
||||||
memorySnapshot = null;
|
memorySnapshot = null;
|
||||||
prefetchPromise = null;
|
prefetchPromise = null;
|
||||||
activeConversationId = '';
|
activeConversationId = '';
|
||||||
|
activeTargetUserId = '';
|
||||||
|
imChatWindowOpen = false;
|
||||||
if (scope) {
|
if (scope) {
|
||||||
sessionCache.remove(`${CACHE_KEY}:${scope}`);
|
sessionCache.remove(`${CACHE_KEY}:${scope}`);
|
||||||
}
|
}
|
||||||
@@ -153,6 +241,7 @@ export function setCachedMessages(
|
|||||||
loadedAt: Date.now(),
|
loadedAt: Date.now(),
|
||||||
};
|
};
|
||||||
saveToSession(snap);
|
saveToSession(snap);
|
||||||
|
notifyImMessagesUpdated(conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendCachedMessage(conversationId: string, msg: ImMessageItem) {
|
export function appendCachedMessage(conversationId: string, msg: ImMessageItem) {
|
||||||
@@ -171,6 +260,7 @@ export function appendCachedMessage(conversationId: string, msg: ImMessageItem)
|
|||||||
entry.records.push(msg);
|
entry.records.push(msg);
|
||||||
entry.loadedAt = Date.now();
|
entry.loadedAt = Date.now();
|
||||||
saveToSession(snap);
|
saveToSession(snap);
|
||||||
|
notifyImMessagesUpdated(conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchCachedMember(
|
export function patchCachedMember(
|
||||||
@@ -258,7 +348,8 @@ export function handleImChatSocket(data: Record<string, any>) {
|
|||||||
}
|
}
|
||||||
const conversationId = data.conversationId as string;
|
const conversationId = data.conversationId as string;
|
||||||
const senderId = data.senderId as string;
|
const senderId = data.senderId as string;
|
||||||
const isActiveConversation = !!conversationId && conversationId === activeConversationId;
|
const isActiveConversation =
|
||||||
|
isImChatUiOpen() && !!conversationId && conversationId === activeConversationId;
|
||||||
|
|
||||||
if (isActiveConversation) {
|
if (isActiveConversation) {
|
||||||
const userStore = useUserStoreWithOut();
|
const userStore = useUserStoreWithOut();
|
||||||
@@ -274,22 +365,35 @@ export function handleImChatSocket(data: Record<string, any>) {
|
|||||||
mine: senderId === currentUserId,
|
mine: senderId === currentUserId,
|
||||||
createTime: data.createTime,
|
createTime: data.createTime,
|
||||||
});
|
});
|
||||||
|
const members = getCachedMembers() || [];
|
||||||
|
const peerMember = members.find((item) => item.conversationId === conversationId);
|
||||||
|
const peerUserId = peerMember?.id || (senderId !== currentUserId ? senderId : activeTargetUserId);
|
||||||
|
if (peerUserId) {
|
||||||
patchCachedMember(
|
patchCachedMember(
|
||||||
senderId,
|
peerUserId,
|
||||||
{
|
{
|
||||||
conversationId,
|
conversationId,
|
||||||
lastContent: data.content,
|
lastContent: formatImMessagePreview(data.content, data.msgType),
|
||||||
lastTime: data.createTime,
|
lastTime: data.createTime,
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
},
|
},
|
||||||
{ moveToTop: true },
|
{ moveToTop: true },
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} else {
|
} 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(
|
patchCachedMember(
|
||||||
senderId,
|
senderId,
|
||||||
{
|
{
|
||||||
conversationId,
|
conversationId,
|
||||||
lastContent: data.content,
|
lastContent: formatImMessagePreview(data.content, data.msgType),
|
||||||
lastTime: data.createTime,
|
lastTime: data.createTime,
|
||||||
},
|
},
|
||||||
{ unreadIncrement: 1 },
|
{ 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 { ref } from 'vue';
|
||||||
import { fetchDeptMembers } from './im.api';
|
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 totalUnread = ref(0);
|
||||||
|
/** 有未读消息的对话数量(与聊天消息列表条数一致) */
|
||||||
|
const conversationUnreadCount = ref(0);
|
||||||
let refreshing = false;
|
let refreshing = false;
|
||||||
|
|
||||||
export function syncImUnreadFromMembers(members: Array<{ unreadCount?: number }>) {
|
function isActiveSessionMember(item: { id?: string; conversationId?: string }) {
|
||||||
totalUnread.value = (members || []).reduce((sum, item) => sum + (item.unreadCount || 0), 0);
|
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) {
|
export async function refreshImUnread(force = false) {
|
||||||
@@ -22,9 +68,9 @@ export async function refreshImUnread(force = false) {
|
|||||||
}
|
}
|
||||||
refreshing = true;
|
refreshing = true;
|
||||||
try {
|
try {
|
||||||
const members = await fetchDeptMembers();
|
const members = ((await fetchDeptMembers()) || []) as Array<{ unreadCount?: number; conversationId?: string; id?: string }>;
|
||||||
setCachedMembers(members || []);
|
setCachedMembers(members);
|
||||||
syncImUnreadFromMembers(members || []);
|
syncImUnreadFromMembers(members);
|
||||||
} finally {
|
} finally {
|
||||||
refreshing = false;
|
refreshing = false;
|
||||||
}
|
}
|
||||||
@@ -33,6 +79,7 @@ export async function refreshImUnread(force = false) {
|
|||||||
export function useImUnread() {
|
export function useImUnread() {
|
||||||
return {
|
return {
|
||||||
totalUnread,
|
totalUnread,
|
||||||
|
conversationUnreadCount,
|
||||||
refreshImUnread,
|
refreshImUnread,
|
||||||
syncImUnreadFromMembers,
|
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">
|
<a-list item-layout="horizontal" :data-source="messageList" :locale="locale">
|
||||||
<template #loadMore>
|
<template #loadMore>
|
||||||
<div
|
<div
|
||||||
v-if="messageList && messageList.length > 0 && !loadEndStatus && !loadingMoreStatus"
|
v-if="messageList && messageList.length > 0"
|
||||||
:style="{ textAlign: 'center', marginTop: '12px', height: '32px', lineHeight: '32px' }"
|
:style="{ textAlign: 'center', marginTop: '12px', height: '32px', lineHeight: '32px' }"
|
||||||
>
|
>
|
||||||
<a-button @click="onLoadMore">加载更多</a-button>
|
<a-button v-if="!loadEndStatus && !loadingMoreStatus" @click="onLoadMore">加载更多</a-button>
|
||||||
</div>
|
<span v-else-if="loadEndStatus">没有更多了</span>
|
||||||
<div
|
|
||||||
v-if="messageList && messageList.length > 0 && loadEndStatus"
|
|
||||||
:style="{ textAlign: 'center', marginTop: '12px', height: '32px', lineHeight: '32px' }"
|
|
||||||
>
|
|
||||||
没有更多了
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -19,7 +14,7 @@
|
|||||||
<a-list-item :style="{ background: item?.izTop && item.izTop == 1 ? '#f7f7f7' : 'auto' }">
|
<a-list-item :style="{ background: item?.izTop && item.izTop == 1 ? '#f7f7f7' : 'auto' }">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<span>{{formatData(item.sendTime)}}</span>
|
<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>
|
</template>
|
||||||
|
|
||||||
<a-list-item-meta>
|
<a-list-item-meta>
|
||||||
@@ -88,6 +83,17 @@
|
|||||||
<a-avatar v-else style="background: #79919d"><AlertOutlined style="font-size: 16px"/></a-avatar>
|
<a-avatar v-else style="background: #79919d"><AlertOutlined style="font-size: 16px"/></a-avatar>
|
||||||
</template>
|
</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>
|
<template v-else>
|
||||||
<a-badge dot v-if="noRead(item)" class="msg-no-read">
|
<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>
|
<a-avatar style="background: #79919d"><bell-filled style="font-size: 16px" title="未读消息"/></a-avatar>
|
||||||
@@ -100,6 +106,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</a-list>
|
</a-list>
|
||||||
|
|
||||||
|
<ImChatModal @register="registerImChatModal" />
|
||||||
|
|
||||||
<keep-alive>
|
<keep-alive>
|
||||||
<component v-if="currentModal" v-bind="bindParams" :key="currentModal" :is="currentModal" @register="modalRegCache[currentModal].register" />
|
<component v-if="currentModal" v-bind="bindParams" :key="currentModal" :is="currentModal" @register="modalRegCache[currentModal].register" />
|
||||||
</keep-alive>
|
</keep-alive>
|
||||||
@@ -112,6 +120,11 @@
|
|||||||
import {getGloablEmojiIndex, useEmojiHtml} from "/@/components/jeecg/comment/useComment";
|
import {getGloablEmojiIndex, useEmojiHtml} from "/@/components/jeecg/comment/useComment";
|
||||||
import { ref, h, watch } from "vue";
|
import { ref, h, watch } from "vue";
|
||||||
import dayjs from 'dayjs';
|
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 {
|
export default {
|
||||||
name: 'SysMessageList',
|
name: 'SysMessageList',
|
||||||
@@ -124,6 +137,7 @@
|
|||||||
InteractionOutlined,
|
InteractionOutlined,
|
||||||
AlertOutlined,
|
AlertOutlined,
|
||||||
GatewayOutlined,
|
GatewayOutlined,
|
||||||
|
ImChatModal,
|
||||||
},
|
},
|
||||||
props:{
|
props:{
|
||||||
star: {
|
star: {
|
||||||
@@ -145,27 +159,42 @@
|
|||||||
},
|
},
|
||||||
emits:['close', 'detail', 'clear', 'close-modal'],
|
emits:['close', 'detail', 'clear', 'close-modal'],
|
||||||
setup(props, {emit}){
|
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 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;
|
let { fromUser, rangeDateKey, rangeDate, noticeType } = params;
|
||||||
searchParams.fromUser = fromUser||'';
|
searchParams.fromUser = fromUser||'';
|
||||||
searchParams.rangeDateKey = rangeDateKey||'';
|
searchParams.rangeDateKey = rangeDateKey||'';
|
||||||
searchParams.rangeDate = rangeDate||[];
|
searchParams.rangeDate = rangeDate||[];
|
||||||
searchParams.noticeType = noticeType || '';
|
searchParams.noticeType = noticeType || '';
|
||||||
//list列表为空时赋初始值
|
|
||||||
locale.value = { locale: { emptyText: `<a-empty />` }};
|
|
||||||
if(props.star===true){
|
if(props.star===true){
|
||||||
searchParams.starFlag = '1'
|
searchParams.starFlag = '1'
|
||||||
}else{
|
}else{
|
||||||
searchParams.starFlag = ''
|
searchParams.starFlag = ''
|
||||||
}
|
}
|
||||||
|
//update-begin---author:cursor ---date:20250528 for:【IM聊天-OA】消息弹窗静默刷新,避免空状态在「暂无数据」与「还剩余未读」间闪烁-----------
|
||||||
|
if (silent) {
|
||||||
|
reloadFresh(true);
|
||||||
|
} else {
|
||||||
reset();
|
reset();
|
||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
|
//update-end---author:cursor ---date:20250528 for:【IM聊天-OA】消息弹窗静默刷新,避免空状态在「暂无数据」与「还剩余未读」间闪烁-----------
|
||||||
|
}
|
||||||
|
|
||||||
function clickStar(item){
|
function clickStar(item){
|
||||||
console.log(item)
|
console.log(item)
|
||||||
@@ -191,7 +220,17 @@
|
|||||||
const emojiIndex = getGloablEmojiIndex()
|
const emojiIndex = getGloablEmojiIndex()
|
||||||
const { getHtml } = useEmojiHtml(emojiIndex);
|
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'
|
record.readFlag = '1'
|
||||||
goPage(record);
|
goPage(record);
|
||||||
emit('close', record.id)
|
emit('close', record.id)
|
||||||
@@ -261,6 +300,11 @@
|
|||||||
//监听信息数量
|
//监听信息数量
|
||||||
watch(() => props.messageCount, (value) => {
|
watch(() => props.messageCount, (value) => {
|
||||||
messageCount.value = 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 })
|
}, { immediate: true })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -281,6 +325,9 @@
|
|||||||
bindParams,
|
bindParams,
|
||||||
locale,
|
locale,
|
||||||
formatData,
|
formatData,
|
||||||
|
registerImChatModal,
|
||||||
|
getImAvatar,
|
||||||
|
getImAvatarText,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
wrapClassName="sys-msg-modal"
|
wrapClassName="sys-msg-modal"
|
||||||
:width="800"
|
:width="800"
|
||||||
:footer="null"
|
:footer="null"
|
||||||
destroyOnClose
|
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="sys-msg-modal-title">
|
<div class="sys-msg-modal-title">
|
||||||
@@ -36,16 +35,18 @@
|
|||||||
>
|
>
|
||||||
标星消息
|
标星消息
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="ant-tabs-ink-bar ant-tabs-ink-bar-animated"
|
@click="(e) => handleChangeTab(e, 'chat')"
|
||||||
:style="{
|
role="tab"
|
||||||
transform: activeKey == 'all' ? 'translate3d(130px, 0px, 0px)' : 'translate3d(215px, 0px, 0px)',
|
aria-disabled="false"
|
||||||
display: 'block',
|
aria-selected="false"
|
||||||
width: '88px',
|
class="ant-tabs-tab im-chat-msg-tab"
|
||||||
height: '1px'
|
:class="{ 'ant-tabs-tab-active': activeKey == 'chat' }"
|
||||||
}"
|
>
|
||||||
></div>
|
聊天消息
|
||||||
|
<a-badge v-if="imConversationUnreadCount > 0" :count="imConversationUnreadCount" :overflow-count="99" class="im-chat-msg-tab-badge" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
<!-- 头部图标 -->
|
<!-- 头部图标 -->
|
||||||
<div class="icon-right">
|
<div class="icon-right">
|
||||||
<div class="icons">
|
<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>
|
<template #content>
|
||||||
<div>
|
<div>
|
||||||
<span class="search-label">回复、提到我的人?:</span>
|
<span class="search-label">回复、提到我的人?:</span>
|
||||||
@@ -121,12 +122,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<a-tab-pane tab="全部消息" key="all" forceRender>
|
<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>
|
||||||
|
|
||||||
<!-- 标星 -->
|
<!-- 标星 -->
|
||||||
<a-tab-pane tab="标星消息" key="star" forceRender>
|
<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-tab-pane>
|
||||||
</a-tabs>
|
</a-tabs>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,11 +147,14 @@
|
|||||||
import { BasicModal, useModalInner, useModal } from '/@/components/Modal';
|
import { BasicModal, useModalInner, useModal } from '/@/components/Modal';
|
||||||
import { FilterOutlined, CloseOutlined, BellFilled, ExclamationOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
import { FilterOutlined, CloseOutlined, BellFilled, ExclamationOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
||||||
import JSelectUser from '/@/components/Form/src/jeecg/components/JSelectUser.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 SysMessageList from './SysMessageList.vue'
|
||||||
import UserSelectModal from '/@/components/Form/src/jeecg/components/modal/UserSelectModal.vue'
|
import UserSelectModal from '/@/components/Form/src/jeecg/components/modal/UserSelectModal.vue'
|
||||||
import DetailModal from '/@/views/monitor/mynews/DetailModal.vue';
|
import DetailModal from '/@/views/monitor/mynews/DetailModal.vue';
|
||||||
import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent';
|
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 calendar from '/@/assets/icons/calendarNotice.png';
|
||||||
import folder from '/@/assets/icons/folderNotice.png';
|
import folder from '/@/assets/icons/folderNotice.png';
|
||||||
import system from '/@/assets/icons/systemNotice.png';
|
import system from '/@/assets/icons/systemNotice.png';
|
||||||
@@ -163,22 +172,26 @@
|
|||||||
JSelectUser,
|
JSelectUser,
|
||||||
// 代码逻辑说明: 【QQYUN-8241】emoji-mart-vue-fast库异步加载
|
// 代码逻辑说明: 【QQYUN-8241】emoji-mart-vue-fast库异步加载
|
||||||
SysMessageList: createAsyncComponent(() => import('./SysMessageList.vue')),
|
SysMessageList: createAsyncComponent(() => import('./SysMessageList.vue')),
|
||||||
|
SysImChatMessageList: createAsyncComponent(() => import('./SysImChatMessageList.vue')),
|
||||||
// SysMessageList,
|
// SysMessageList,
|
||||||
UserSelectModal,
|
UserSelectModal,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
DetailModal
|
DetailModal
|
||||||
},
|
},
|
||||||
props:{
|
props:{
|
||||||
messageCount: {
|
systemMessageCount: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits:['register', 'refresh'],
|
emits:['register', 'refresh'],
|
||||||
setup(_p, {emit}) {
|
setup(props, {emit}) {
|
||||||
const allMessageRef = ref();
|
const allMessageRef = ref();
|
||||||
const starMessageRef = ref();
|
const starMessageRef = ref();
|
||||||
|
const chatMessageRef = ref();
|
||||||
const activeKey = ref('all');
|
const activeKey = ref('all');
|
||||||
|
const { totalUnread: imUnreadCount, conversationUnreadCount: imConversationUnreadCount, refreshImUnread } = useImUnread();
|
||||||
|
|
||||||
//通知类型
|
//通知类型
|
||||||
const noticeType = ref('');
|
const noticeType = ref('');
|
||||||
//通知类型数组
|
//通知类型数组
|
||||||
@@ -187,11 +200,12 @@
|
|||||||
{ key: 'flow', text: '流程通知', active: false, img: flow },
|
{ key: 'flow', text: '流程通知', active: false, img: flow },
|
||||||
{ key: 'plan', text: '日程通知', active: false, img: calendar },
|
{ key: 'plan', text: '日程通知', active: false, img: calendar },
|
||||||
{ key: 'file', text: '知识通知', active: false, img: folder },
|
{ key: 'file', text: '知识通知', active: false, img: folder },
|
||||||
|
{ key: 'chat', text: '聊天消息', active: false, img: collaboration },
|
||||||
]);
|
]);
|
||||||
const noticeImg = ref('');
|
const noticeImg = ref('');
|
||||||
function handleChangeTab(e, key) {
|
function handleChangeTab(e, key) {
|
||||||
activeKey.value = key;
|
activeKey.value = key;
|
||||||
loadData();
|
loadData(false);
|
||||||
}
|
}
|
||||||
function handleChangePanel(key) {
|
function handleChangePanel(key) {
|
||||||
activeKey.value = key;
|
activeKey.value = key;
|
||||||
@@ -205,7 +219,19 @@
|
|||||||
rangeDate: [],
|
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 = {
|
let params = {
|
||||||
fromUser: searchParams.fromUser,
|
fromUser: searchParams.fromUser,
|
||||||
rangeDateKey: searchParams.rangeDateKey,
|
rangeDateKey: searchParams.rangeDateKey,
|
||||||
@@ -214,12 +240,17 @@
|
|||||||
}
|
}
|
||||||
if(activeKey.value == 'all'){
|
if(activeKey.value == 'all'){
|
||||||
getRefPromise(allMessageRef).then(() => {
|
getRefPromise(allMessageRef).then(() => {
|
||||||
allMessageRef.value.reload(params);
|
allMessageRef.value.reload(params, silent);
|
||||||
});
|
});
|
||||||
}else{
|
} else if (activeKey.value == 'chat') {
|
||||||
starMessageRef.value.reload(params);
|
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);
|
const isLowApp = ref(false);
|
||||||
//useModalInner
|
//useModalInner
|
||||||
@@ -238,8 +269,9 @@
|
|||||||
}
|
}
|
||||||
delete data.noticeType;
|
delete data.noticeType;
|
||||||
}
|
}
|
||||||
//每次弹窗打开 加载最新的数据
|
//每次弹窗打开 后台静默加载最新数据
|
||||||
loadData();
|
refreshImUnread(false);
|
||||||
|
loadData(true);
|
||||||
if(data){
|
if(data){
|
||||||
isLowApp.value = data.isLowApp||false
|
isLowApp.value = data.isLowApp||false
|
||||||
}else{
|
}else{
|
||||||
@@ -405,6 +437,36 @@
|
|||||||
loadData();
|
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 {
|
return {
|
||||||
conditionStr,
|
conditionStr,
|
||||||
regModal,
|
regModal,
|
||||||
@@ -431,6 +493,9 @@
|
|||||||
|
|
||||||
allMessageRef,
|
allMessageRef,
|
||||||
starMessageRef,
|
starMessageRef,
|
||||||
|
chatMessageRef,
|
||||||
|
imUnreadCount,
|
||||||
|
imConversationUnreadCount,
|
||||||
registerDetail,
|
registerDetail,
|
||||||
showDetailModal,
|
showDetailModal,
|
||||||
isLowApp,
|
isLowApp,
|
||||||
@@ -536,23 +601,42 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
|
||||||
.ant-tabs-tab {
|
&::after {
|
||||||
position: relative;
|
content: '';
|
||||||
display: inline-flex;
|
position: absolute;
|
||||||
align-items: center;
|
left: 0;
|
||||||
padding: 12px 0;
|
right: 0;
|
||||||
font-size: 14px;
|
bottom: 0;
|
||||||
|
height: 2px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
transition: background-color 0.2s;
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.ant-tabs-tab+.ant-tabs-tab {
|
}
|
||||||
|
|
||||||
|
.ant-tabs-tab-active::after {
|
||||||
|
background: @primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs-tab + .ant-tabs-tab {
|
||||||
margin: 0 0 0 32px;
|
margin: 0 0 0 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-tabs-ink-bar {
|
.ant-tabs-ink-bar {
|
||||||
background: @primary-color;
|
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 {
|
.ant-tabs-nav-scroll {
|
||||||
|
|||||||
@@ -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 { useTabs } from '/@/hooks/web/useTabs';
|
||||||
import { useModal } from '/@/components/Modal';
|
import { useModal } from '/@/components/Modal';
|
||||||
import {useMessage} from "/@/hooks/web/useMessage";
|
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() {
|
function getQueryParams() {
|
||||||
let { fromUser, rangeDateKey, rangeDate, starFlag, noticeType } = searchParams;
|
let { fromUser, rangeDateKey, rangeDate, starFlag } = searchParams;
|
||||||
let params = {
|
let params = {
|
||||||
fromUser,
|
fromUser,
|
||||||
starFlag,
|
starFlag,
|
||||||
@@ -51,7 +62,7 @@ export function useSysMessage(setLocaleText) {
|
|||||||
endDate: '',
|
endDate: '',
|
||||||
pageNo: pageNo.value,
|
pageNo: pageNo.value,
|
||||||
pageSize,
|
pageSize,
|
||||||
noticeType
|
noticeType: getSystemNoticeType(),
|
||||||
};
|
};
|
||||||
if (rangeDateKey == 'zdy') {
|
if (rangeDateKey == 'zdy') {
|
||||||
params.beginDate = rangeDate[0]+' 00:00:00';
|
params.beginDate = rangeDate[0]+' 00:00:00';
|
||||||
@@ -60,29 +71,80 @@ export function useSysMessage(setLocaleText) {
|
|||||||
return params;
|
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);
|
const loadEndStatus = ref(false);
|
||||||
|
|
||||||
//请求数据
|
//请求数据
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
if(loadEndStatus.value === true){
|
if (loadEndStatus.value === true) {
|
||||||
return;
|
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);
|
const data = await queryMessageList(params);
|
||||||
console.log('获取结果', data);
|
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;
|
loadEndStatus.value = true;
|
||||||
setLocaleText();
|
setLocaleText();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(data.length<pageSize){
|
appendSystemPage(data);
|
||||||
loadEndStatus.value = true;
|
|
||||||
}
|
|
||||||
pageNo.value = pageNo.value+1
|
|
||||||
let temp:any[] = messageList.value;
|
|
||||||
temp.push(...data);
|
|
||||||
messageList.value = temp;
|
|
||||||
setLocaleText();
|
setLocaleText();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +155,68 @@ export function useSysMessage(setLocaleText) {
|
|||||||
loadEndStatus.value = false;
|
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){
|
async function updateStarMessage(item){
|
||||||
const url = '/sys/sysAnnouncementSend/edit';
|
const url = '/sys/sysAnnouncementSend/edit';
|
||||||
@@ -145,6 +269,8 @@ export function useSysMessage(setLocaleText) {
|
|||||||
return '督办催办:';
|
return '督办催办:';
|
||||||
} else if (item.busType == 'eoa_sup_notify') {
|
} else if (item.busType == 'eoa_sup_notify') {
|
||||||
return '督办提醒:';
|
return '督办提醒:';
|
||||||
|
} else if (item.busType == IM_CHAT_BUS_TYPE) {
|
||||||
|
return '聊天消息:';
|
||||||
} else if (item.msgCategory == '2') {
|
} else if (item.msgCategory == '2') {
|
||||||
return '系统消息:';
|
return '系统消息:';
|
||||||
} else if (item.msgCategory == '1') {
|
} else if (item.msgCategory == '1') {
|
||||||
@@ -155,6 +281,9 @@ export function useSysMessage(setLocaleText) {
|
|||||||
|
|
||||||
// QQYUN-4472 来消息了没有提醒--查看详情改为去处理
|
// QQYUN-4472 来消息了没有提醒--查看详情改为去处理
|
||||||
function getHrefText(item) {
|
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.busType === 'bpm'|| item.busType === 'bpm_task' || item.busType === 'tenant_invite'){
|
||||||
//判断是否是查看详情
|
//判断是否是查看详情
|
||||||
if (item.msgAbstract) {
|
if (item.msgAbstract) {
|
||||||
@@ -180,6 +309,7 @@ export function useSysMessage(setLocaleText) {
|
|||||||
messageList,
|
messageList,
|
||||||
reset,
|
reset,
|
||||||
loadData,
|
loadData,
|
||||||
|
reloadFresh,
|
||||||
loadEndStatus,
|
loadEndStatus,
|
||||||
searchParams,
|
searchParams,
|
||||||
updateStarMessage,
|
updateStarMessage,
|
||||||
|
|||||||
Reference in New Issue
Block a user