Merge branch '20260519-3.9.2版本-葛昊天分支'

This commit is contained in:
geht
2026-05-29 10:50:42 +08:00
27 changed files with 2698 additions and 180 deletions

View File

@@ -10,6 +10,15 @@ import { useUserStore } from '/@/store/modules/user';
let result: WebSocketResult<any>;
const listeners = new Map();
let connectedUrl = '';
//update-begin---author:xsl ---date:20260528 for【IM聊天】WS 重连后通知监听方重新拉取消息-----------
let wsConnectionCount = 0;
const reconnectListeners = new Set<() => void>();
export function onWebSocketReconnect(callback: () => void) {
reconnectListeners.add(callback);
return () => reconnectListeners.delete(callback);
}
//update-end---author:xsl ---date:20260528 for【IM聊天】WS 重连后通知监听方重新拉取消息-----------
/**
* 构建系统 WebSocket 地址(含 context-path如 /jeecg-boot
@@ -67,7 +76,15 @@ export function connectWebSocket(url: string) {
protocols: [token],
// 代码逻辑说明: [issues/6662] 演示系统socket总断换一个写法
onConnected: function (ws) {
wsConnectionCount++;
console.log('[WebSocket] 连接成功', ws);
//update-begin---author:xsl ---date:20260528 for【IM聊天】WS 重连后通知监听方重新拉取消息-----------
if (wsConnectionCount > 1) {
reconnectListeners.forEach((cb) => {
try { cb(); } catch (err) { console.error(err); }
});
}
//update-end---author:xsl ---date:20260528 for【IM聊天】WS 重连后通知监听方重新拉取消息-----------
},
onDisconnected: function (ws, event) {
console.log('[WebSocket] 连接断开:', ws, event);

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@
import { ref, nextTick, onMounted, onUnmounted } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import ImChat from './ImChat.vue';
import { setImChatWindowOpen } from './imCache';
import { setImChatWindowOpen, setImActiveTargetUserId } from './imCache';
import { canOpenImChatModal, onImChatModalCloseRequest, openImChat, setImPageContext } from './imSession';
import { refreshImUnread } from './useImUnread';
@@ -28,29 +28,53 @@
const imChatRef = ref<InstanceType<typeof ImChat>>();
const pendingTargetUserId = ref('');
//update-begin---author:xsl ---date:20260528 for【IM聊天】支持从消息提醒直接打开群聊-----------
const pendingConversationId = ref('');
//update-end---author:xsl ---date:20260528 for【IM聊天】支持从消息提醒直接打开群聊-----------
function restoreChatSession() {
nextTick(async () => {
const targetUserId = pendingTargetUserId.value;
//update-begin---author:xsl ---date:20260528 for【IM聊天】支持从消息提醒直接打开群聊-----------
const conversationId = pendingConversationId.value;
//update-end---author:xsl ---date:20260528 for【IM聊天】支持从消息提醒直接打开群聊-----------
if (targetUserId) {
pendingTargetUserId.value = '';
//update-begin---author:xsl ---date:20260528 for【IM聊天】预设本地活跃会话防止 WS 消息在异步初始化期间丢失-----------
imChatRef.value?.presetActivePeer?.(targetUserId);
//update-end---author:xsl ---date:20260528 for【IM聊天】预设本地活跃会话防止 WS 消息在异步初始化期间丢失-----------
await imChatRef.value?.openTargetChat?.(targetUserId);
return;
}
//update-begin---author:xsl ---date:20260528 for【IM聊天】支持从消息提醒直接打开群聊-----------
if (conversationId) {
pendingConversationId.value = '';
await imChatRef.value?.openGroupConversation?.(conversationId);
return;
}
//update-end---author:xsl ---date:20260528 for【IM聊天】支持从消息提醒直接打开群聊-----------
imChatRef.value?.restoreSessionIfNeeded?.();
});
}
const [registerModal, { closeModal }] = useModalInner((data?: { targetUserId?: string }) => {
const [registerModal, { closeModal }] = useModalInner((data?: { targetUserId?: string; conversationId?: string }) => {
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】全页 IM 已打开时拒绝弹窗,改由全页承接-----------
if (!canOpenImChatModal()) {
closeModal();
openImChat({ targetUserId: data?.targetUserId });
openImChat({ targetUserId: data?.targetUserId, conversationId: data?.conversationId });
return;
}
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】全页 IM 已打开时拒绝弹窗,改由全页承接-----------
setImChatWindowOpen(true);
pendingTargetUserId.value = data?.targetUserId || '';
//update-begin---author:xsl ---date:20260528 for【IM聊天】支持从消息提醒直接打开群聊-----------
pendingConversationId.value = data?.conversationId || '';
//update-end---author:xsl ---date:20260528 for【IM聊天】支持从消息提醒直接打开群聊-----------
//update-begin---author:xsl ---date:20260528 for【IM聊天】弹窗打开立即预热全局活跃 userId避免 WS 消息在 nextTick 前丢失-----------
if (data?.targetUserId) {
setImActiveTargetUserId(data.targetUserId);
}
//update-end---author:xsl ---date:20260528 for【IM聊天】弹窗打开立即预热全局活跃 userId避免 WS 消息在 nextTick 前丢失-----------
restoreChatSession();
});

View File

@@ -0,0 +1,161 @@
<template>
<BasicModal v-bind="$attrs" title="发起群聊" :width="520" @register="registerModal" @ok="handleSubmit">
<a-form layout="vertical">
<a-form-item label="群名称" required>
<a-input v-model:value="groupName" placeholder="请输入群名称" :maxlength="30" show-count />
</a-form-item>
<a-form-item label="选择成员" required>
<a-input
v-model:value="memberKeyword"
placeholder="搜索同事"
allow-clear
size="small"
class="member-search"
@pressEnter="loadMembers"
/>
<a-spin :spinning="loading">
<div class="member-list">
<a-checkbox-group v-model:value="checkedMemberIds" class="member-checkbox-group">
<div v-for="item in filteredMembers" :key="item.id" class="member-row">
<a-checkbox :value="item.id">
<div class="member-info">
<a-avatar :size="28" :src="getAvatarUrl(item.avatar)">
{{ (item.realname || item.username || '?').slice(0, 1) }}
</a-avatar>
<span class="member-name">{{ item.realname || item.username }}</span>
</div>
</a-checkbox>
</div>
</a-checkbox-group>
<a-empty v-if="!filteredMembers.length" description="暂无可选同事" />
</div>
</a-spin>
</a-form-item>
</a-form>
</BasicModal>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
import { useUserStore } from '/@/store/modules/user';
import { createGroupConversation, fetchDeptMembers } from './im.api';
import { getCachedMembers } from './imCache';
defineOptions({ name: 'ImCreateGroupModal' });
const userStore = useUserStore();
const emit = defineEmits<{
success: [group: Recordable];
}>();
const { createMessage } = useMessage();
const groupName = ref('');
const memberKeyword = ref('');
const checkedMemberIds = ref<string[]>([]);
const members = ref<Recordable[]>([]);
const loading = ref(false);
const filteredMembers = computed(() => {
const keyword = memberKeyword.value.trim().toLowerCase();
if (!keyword) {
return members.value;
}
return members.value.filter((item) => {
const name = `${item.realname || ''}${item.username || ''}`.toLowerCase();
return name.includes(keyword);
});
});
function resolveSelectableMembers(source?: Recordable[]) {
const currentUserId = userStore.getUserInfo?.id || '';
const list = source?.length ? source : getCachedMembers() || [];
return list.filter((item) => item.id && item.id !== currentUserId);
}
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data?: { members?: Recordable[] }) => {
groupName.value = '';
memberKeyword.value = '';
checkedMemberIds.value = [];
setModalProps({ confirmLoading: false });
members.value = resolveSelectableMembers(data?.members);
await loadMembers();
});
function getAvatarUrl(avatar?: string) {
return avatar ? getFileAccessHttpUrl(avatar) : '';
}
async function loadMembers() {
loading.value = true;
try {
const list = ((await fetchDeptMembers(memberKeyword.value || undefined)) || []) as Recordable[];
members.value = resolveSelectableMembers(list);
} catch {
createMessage.error('加载同事列表失败,请稍后重试');
} finally {
loading.value = false;
}
}
async function handleSubmit() {
const name = groupName.value.trim();
if (!name) {
createMessage.warning('请输入群名称');
return;
}
if (!checkedMemberIds.value.length) {
createMessage.warning('请至少选择1名同事');
return;
}
setModalProps({ confirmLoading: true });
try {
const group = await createGroupConversation({
groupName: name,
memberUserIds: checkedMemberIds.value,
});
emit('success', group);
closeModal();
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>
<style lang="less" scoped>
.member-search {
margin-bottom: 8px;
}
.member-list {
max-height: 320px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 8px;
}
.member-checkbox-group {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.member-row {
padding: 4px 0;
}
.member-info {
display: inline-flex;
align-items: center;
gap: 8px;
}
.member-name {
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,164 @@
<!--update-begin---author:cursor ---date:20260529 forIM聊天-OA群设置-添加群成员弹窗----------->
<template>
<BasicModal v-bind="$attrs" title="添加群成员" :width="520" @register="registerModal" @ok="handleSubmit">
<a-form layout="vertical">
<a-form-item label="选择成员" required>
<a-input
v-model:value="memberKeyword"
placeholder="搜索同事"
allow-clear
size="small"
class="member-search"
@pressEnter="loadMembers"
/>
<a-spin :spinning="loading">
<div class="member-list">
<a-checkbox-group v-model:value="checkedMemberIds" class="member-checkbox-group">
<div v-for="item in filteredMembers" :key="item.id" class="member-row">
<a-checkbox :value="item.id">
<div class="member-info">
<a-avatar :size="28" :src="getAvatarUrl(item.avatar)">
{{ (item.realname || item.username || '?').slice(0, 1) }}
</a-avatar>
<span class="member-name">{{ item.realname || item.username }}</span>
</div>
</a-checkbox>
</div>
</a-checkbox-group>
<a-empty v-if="!filteredMembers.length" description="暂无可添加的同事" />
</div>
</a-spin>
</a-form-item>
</a-form>
</BasicModal>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
import { useUserStore } from '/@/store/modules/user';
import { addGroupMembers, fetchDeptMembers } from './im.api';
defineOptions({ name: 'ImGroupAddMemberModal' });
const userStore = useUserStore();
const emit = defineEmits<{
success: [];
}>();
const { createMessage } = useMessage();
const memberKeyword = ref('');
const checkedMemberIds = ref<string[]>([]);
const members = ref<Recordable[]>([]);
const loading = ref(false);
const conversationId = ref('');
// 已在群成员(用于过滤候选)
const existMemberIds = ref<string[]>([]);
const filteredMembers = computed(() => {
const keyword = memberKeyword.value.trim().toLowerCase();
if (!keyword) {
return members.value;
}
return members.value.filter((item) => {
const name = `${item.realname || ''}${item.username || ''}`.toLowerCase();
return name.includes(keyword);
});
});
/** 过滤掉当前用户与已在群成员 */
function resolveSelectableMembers(source: Recordable[]) {
const currentUserId = userStore.getUserInfo?.id || '';
const excludeSet = new Set([currentUserId, ...existMemberIds.value]);
return source.filter((item) => item.id && !excludeSet.has(item.id));
}
const [registerModal, { setModalProps, closeModal }] = useModalInner(
async (data?: { conversationId?: string; existMemberIds?: string[] }) => {
memberKeyword.value = '';
checkedMemberIds.value = [];
conversationId.value = data?.conversationId || '';
existMemberIds.value = data?.existMemberIds || [];
setModalProps({ confirmLoading: false });
members.value = [];
await loadMembers();
},
);
function getAvatarUrl(avatar?: string) {
return avatar ? getFileAccessHttpUrl(avatar) : '';
}
async function loadMembers() {
loading.value = true;
try {
const list = ((await fetchDeptMembers(memberKeyword.value || undefined)) || []) as Recordable[];
members.value = resolveSelectableMembers(list);
} catch {
createMessage.error('加载同事列表失败,请稍后重试');
} finally {
loading.value = false;
}
}
async function handleSubmit() {
if (!conversationId.value) {
return;
}
if (!checkedMemberIds.value.length) {
createMessage.warning('请至少选择1名同事');
return;
}
setModalProps({ confirmLoading: true });
try {
await addGroupMembers({
conversationId: conversationId.value,
memberUserIds: checkedMemberIds.value,
});
createMessage.success('添加成功');
emit('success');
closeModal();
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>
<style lang="less" scoped>
.member-search {
margin-bottom: 8px;
}
.member-list {
max-height: 320px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 8px;
}
.member-checkbox-group {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.member-row {
padding: 4px 0;
}
.member-info {
display: inline-flex;
align-items: center;
gap: 8px;
}
.member-name {
font-size: 13px;
}
</style>
<!--update-end---author:cursor ---date:20260529 forIM聊天-OA群设置-添加群成员弹窗----------->

View File

@@ -0,0 +1,473 @@
<!--update-begin---author:cursor ---date:20260529 forIM聊天-OA群设置抽屉----------->
<template>
<BasicDrawer v-bind="$attrs" title="群设置" :width="360" @register="registerDrawer">
<a-spin :spinning="loading">
<div class="group-setting">
<!-- 群成员宫格 -->
<div class="section">
<div class="section-title">
群成员
<span class="member-count">{{ detail.memberCount || 0 }}</span>
</div>
<div class="member-grid">
<div v-for="m in detail.members" :key="m.userId" class="member-cell">
<div class="member-avatar-wrap">
<a-avatar :size="44" :src="getAvatarUrl(m.avatar)">
{{ (m.realname || m.username || '?').slice(0, 1) }}
</a-avatar>
<span v-if="m.owner" class="owner-tag">群主</span>
<span
v-if="removeMode && !m.owner"
class="remove-badge"
@click="handleRemoveMember(m)"
>
<Icon icon="ant-design:minus-outlined" />
</span>
</div>
<span class="member-name">{{ m.realname || m.username }}</span>
</div>
<!-- 添加成员所有成员可见 -->
<div class="member-cell">
<div class="member-action-btn" @click="handleOpenAddMember">
<Icon icon="ant-design:plus-outlined" />
</div>
<span class="member-name">添加</span>
</div>
<!-- 移除成员仅群主可见 -->
<div v-if="detail.owner" class="member-cell">
<div :class="['member-action-btn', { active: removeMode }]" @click="toggleRemoveMode">
<Icon icon="ant-design:minus-outlined" />
</div>
<span class="member-name">{{ removeMode ? '完成' : '移除' }}</span>
</div>
</div>
</div>
<!-- 群名称 -->
<div class="section">
<div class="setting-row" :class="{ 'is-clickable': detail.owner }" @click="handleEditName">
<span class="row-label">群聊名称</span>
<span class="row-value">
{{ detail.groupName || '未命名群聊' }}
<Icon v-if="detail.owner" icon="ant-design:right-outlined" class="row-arrow" />
</span>
</div>
</div>
<!-- 群主管理 -->
<div v-if="detail.owner" class="section">
<div class="setting-row is-clickable" @click="handleOpenTransfer">
<span class="row-label">转让群主</span>
<Icon icon="ant-design:right-outlined" class="row-arrow" />
</div>
</div>
<!-- 底部操作 -->
<div class="footer-actions">
<a-button v-if="detail.owner" danger block @click="handleDismiss">解散群聊</a-button>
<a-button v-else danger block @click="handleQuit">退出群聊</a-button>
</div>
</div>
</a-spin>
<!-- 添加成员弹窗 -->
<ImGroupAddMemberModal @register="registerAddMemberModal" @success="onMembersChanged" />
<!-- 修改群名称弹窗 -->
<a-modal v-model:open="nameModalVisible" title="修改群名称" :confirm-loading="nameSaving" @ok="submitRename">
<a-input v-model:value="editingName" placeholder="请输入群名称" :maxlength="30" show-count />
</a-modal>
<!-- 转让群主弹窗 -->
<a-modal v-model:open="transferModalVisible" title="转让群主" :confirm-loading="transferSaving" @ok="submitTransfer">
<div class="transfer-tip">选择一名群成员作为新群主转让后你将不再是群主</div>
<a-radio-group v-model:value="transferTargetId" class="transfer-list">
<a-radio v-for="m in transferCandidates" :key="m.userId" :value="m.userId" class="transfer-item">
<a-avatar :size="28" :src="getAvatarUrl(m.avatar)">
{{ (m.realname || m.username || '?').slice(0, 1) }}
</a-avatar>
<span class="transfer-name">{{ m.realname || m.username }}</span>
</a-radio>
</a-radio-group>
<a-empty v-if="!transferCandidates.length" description="暂无其他群成员" />
</a-modal>
</BasicDrawer>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue';
import { BasicDrawer, useDrawerInner } from '/@/components/Drawer';
import { useModal } from '/@/components/Modal';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
import {
fetchGroupDetail,
removeGroupMember,
renameGroup,
transferGroupOwner,
quitGroup,
dismissGroup,
type ImGroupDetail,
type ImGroupMember,
} from './im.api';
import ImGroupAddMemberModal from './ImGroupAddMemberModal.vue';
defineOptions({ name: 'ImGroupSettingDrawer' });
const emit = defineEmits<{
/** 群信息变更(改名/加人/踢人/转让),父组件据此刷新会话 */
(e: 'changed', payload: { conversationId: string; groupName?: string; memberCount?: number }): void;
/** 退出或解散群聊,父组件据此移除会话并清理当前会话 */
(e: 'exited', conversationId: string): void;
}>();
const { createMessage, createConfirm } = useMessage();
const loading = ref(false);
const conversationId = ref('');
const detail = reactive<ImGroupDetail>({
conversationId: '',
groupName: '',
ownerId: '',
memberCount: 0,
owner: false,
members: [],
});
const removeMode = ref(false);
// 改名
const nameModalVisible = ref(false);
const editingName = ref('');
const nameSaving = ref(false);
// 转让群主
const transferModalVisible = ref(false);
const transferTargetId = ref('');
const transferSaving = ref(false);
const transferCandidates = computed(() => detail.members.filter((m) => !m.owner));
const [registerAddMemberModal, { openModal: openAddMemberModal }] = useModal();
const [registerDrawer, { setDrawerProps }] = useDrawerInner(async (data?: { conversationId?: string }) => {
removeMode.value = false;
conversationId.value = data?.conversationId || '';
if (!conversationId.value) {
return;
}
setDrawerProps({ loading: false });
await loadDetail();
});
function getAvatarUrl(avatar?: string) {
return avatar ? getFileAccessHttpUrl(avatar) : '';
}
function applyDetail(data: ImGroupDetail) {
detail.conversationId = data.conversationId;
detail.groupName = data.groupName;
detail.ownerId = data.ownerId;
detail.memberCount = data.memberCount;
detail.owner = data.owner;
detail.members = data.members || [];
}
async function loadDetail() {
loading.value = true;
try {
const data = await fetchGroupDetail(conversationId.value);
applyDetail(data);
} finally {
loading.value = false;
}
}
function notifyChanged() {
emit('changed', {
conversationId: conversationId.value,
groupName: detail.groupName,
memberCount: detail.memberCount,
});
}
function handleOpenAddMember() {
openAddMemberModal(true, {
conversationId: conversationId.value,
existMemberIds: detail.members.map((m) => m.userId),
});
}
async function onMembersChanged() {
await loadDetail();
notifyChanged();
}
function toggleRemoveMode() {
removeMode.value = !removeMode.value;
}
function handleRemoveMember(member: ImGroupMember) {
createConfirm({
iconType: 'warning',
title: '移除成员',
content: `确定将「${member.realname || member.username}」移出群聊吗?`,
onOk: async () => {
await removeGroupMember(conversationId.value, member.userId);
createMessage.success('已移除');
await loadDetail();
notifyChanged();
},
});
}
function handleEditName() {
if (!detail.owner) {
return;
}
editingName.value = detail.groupName || '';
nameModalVisible.value = true;
}
async function submitRename() {
const name = editingName.value.trim();
if (!name) {
createMessage.warning('请输入群名称');
return;
}
nameSaving.value = true;
try {
await renameGroup(conversationId.value, name);
detail.groupName = name;
nameModalVisible.value = false;
createMessage.success('修改成功');
notifyChanged();
} finally {
nameSaving.value = false;
}
}
function handleOpenTransfer() {
transferTargetId.value = '';
transferModalVisible.value = true;
}
async function submitTransfer() {
if (!transferTargetId.value) {
createMessage.warning('请选择新群主');
return;
}
transferSaving.value = true;
try {
await transferGroupOwner(conversationId.value, transferTargetId.value);
transferModalVisible.value = false;
createMessage.success('转让成功');
await loadDetail();
notifyChanged();
} finally {
transferSaving.value = false;
}
}
function handleQuit() {
createConfirm({
iconType: 'warning',
title: '退出群聊',
content: '退出后将不再接收该群消息,确定退出吗?',
onOk: async () => {
await quitGroup(conversationId.value);
createMessage.success('已退出群聊');
emit('exited', conversationId.value);
},
});
}
function handleDismiss() {
createConfirm({
iconType: 'warning',
title: '解散群聊',
content: '解散后群聊将被删除且无法恢复,确定解散吗?',
onOk: async () => {
await dismissGroup(conversationId.value);
createMessage.success('群聊已解散');
emit('exited', conversationId.value);
},
});
}
</script>
<style lang="less" scoped>
.group-setting {
padding: 4px 4px 16px;
}
.section {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.section-title {
font-size: 13px;
color: #888;
margin-bottom: 12px;
.member-count {
color: #aaa;
}
}
.member-grid {
display: flex;
flex-wrap: wrap;
gap: 12px 8px;
}
.member-cell {
width: 56px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.member-avatar-wrap {
position: relative;
}
.owner-tag {
position: absolute;
left: 50%;
bottom: -4px;
transform: translateX(-50%);
font-size: 10px;
line-height: 14px;
padding: 0 4px;
color: #fff;
background: #fa8c16;
border-radius: 7px;
white-space: nowrap;
}
.remove-badge {
position: absolute;
top: -4px;
right: -4px;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #ff4d4f;
color: #fff;
border-radius: 50%;
font-size: 12px;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.member-action-btn {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px dashed #d9d9d9;
border-radius: 6px;
color: #999;
font-size: 18px;
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: #1677ff;
color: #1677ff;
}
&.active {
border-color: #ff4d4f;
color: #ff4d4f;
}
}
.member-name {
font-size: 12px;
color: #595959;
max-width: 56px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 28px;
&.is-clickable {
cursor: pointer;
}
}
.row-label {
font-size: 14px;
color: #262626;
flex-shrink: 0;
}
.row-value {
font-size: 13px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
}
.row-arrow {
color: #c0c0c0;
font-size: 12px;
}
.footer-actions {
margin-top: 24px;
}
.transfer-tip {
color: #888;
font-size: 13px;
margin-bottom: 12px;
}
.transfer-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 320px;
overflow-y: auto;
width: 100%;
}
.transfer-item {
display: inline-flex;
align-items: center;
:deep(.ant-radio + span) {
display: inline-flex;
align-items: center;
gap: 8px;
}
}
.transfer-name {
font-size: 13px;
}
</style>
<!--update-end---author:cursor ---date:20260529 forIM聊天-OA群设置抽屉----------->

View File

@@ -6,6 +6,28 @@ enum Api {
messages = '/sys/im/chat/messages',
send = '/sys/im/chat/send',
read = '/sys/im/chat/read',
groups = '/sys/im/chat/groups',
groupCreate = '/sys/im/chat/group/create',
//update-begin---author:cursor ---date:20260529 for【IM聊天-OA】群设置接口-----------
groupDetail = '/sys/im/chat/group/detail',
groupAddMembers = '/sys/im/chat/group/addMembers',
groupRemoveMember = '/sys/im/chat/group/removeMember',
groupRename = '/sys/im/chat/group/rename',
groupTransfer = '/sys/im/chat/group/transfer',
groupQuit = '/sys/im/chat/group/quit',
groupDismiss = '/sys/im/chat/group/dismiss',
//update-end---author:cursor ---date:20260529 for【IM聊天-OA】群设置接口-----------
}
export interface ImGroupConversation {
conversationId: string;
convType?: string;
groupName?: string;
memberCount?: number;
ownerId?: string;
lastContent?: string;
lastTime?: string;
unreadCount?: number;
}
export const fetchDeptMembers = (keyword?: string) => defHttp.get({ url: Api.deptMembers, params: { keyword } });
@@ -39,3 +61,56 @@ export const sendMessage = (data: { conversationId: string; content: string; msg
export const markRead = (conversationId: string) =>
defHttp.put({ url: Api.read, params: { conversationId } }, { joinParamsToUrl: true, successMessageMode: 'none' });
export const fetchGroups = () => defHttp.get<ImGroupConversation[]>({ url: Api.groups });
export const createGroupConversation = (data: { groupName: string; memberUserIds: string[] }) =>
defHttp.post<ImGroupConversation>({ url: Api.groupCreate, data });
//update-begin---author:cursor ---date:20260529 for【IM聊天-OA】群设置接口-----------
export interface ImGroupMember {
userId: string;
realname?: string;
username?: string;
avatar?: string;
owner?: boolean;
}
export interface ImGroupDetail {
conversationId: string;
groupName?: string;
ownerId?: string;
memberCount?: number;
/** 当前登录用户是否群主 */
owner?: boolean;
members: ImGroupMember[];
}
/** 群聊详情(含成员列表) */
export const fetchGroupDetail = (conversationId: string) =>
defHttp.get<ImGroupDetail>({ url: Api.groupDetail, params: { conversationId } });
/** 添加群成员 */
export const addGroupMembers = (data: { conversationId: string; memberUserIds: string[] }) =>
defHttp.post<ImGroupConversation>({ url: Api.groupAddMembers, data });
/** 移除群成员(仅群主) */
export const removeGroupMember = (conversationId: string, memberUserId: string) =>
defHttp.post({ url: Api.groupRemoveMember, params: { conversationId, memberUserId } }, { joinParamsToUrl: true });
/** 修改群名称(仅群主) */
export const renameGroup = (conversationId: string, groupName: string) =>
defHttp.post<ImGroupConversation>({ url: Api.groupRename, params: { conversationId, groupName } }, { joinParamsToUrl: true });
/** 转让群主(仅群主) */
export const transferGroupOwner = (conversationId: string, newOwnerId: string) =>
defHttp.post({ url: Api.groupTransfer, params: { conversationId, newOwnerId } }, { joinParamsToUrl: true });
/** 退出群聊(非群主成员) */
export const quitGroup = (conversationId: string) =>
defHttp.post({ url: Api.groupQuit, params: { conversationId } }, { joinParamsToUrl: true });
/** 解散群聊(仅群主) */
export const dismissGroup = (conversationId: string) =>
defHttp.post({ url: Api.groupDismiss, params: { conversationId } }, { joinParamsToUrl: true });
//update-end---author:cursor ---date:20260529 for【IM聊天-OA】群设置接口-----------

View File

@@ -1,7 +1,8 @@
import dayjs from 'dayjs';
import { ref as vueRef } from 'vue';
import { createSessionStorage } from '/@/utils/cache';
import { useUserStoreWithOut } from '/@/store/modules/user';
import { fetchDeptMembers, fetchMessages } from './im.api';
import { fetchDeptMembers, fetchMessages, fetchGroups } from './im.api';
import { getImDefaultStartTime } from './imSettings';
import { formatImMessagePreview } from './imMessageUtil';
import { syncImUnreadFromMembers } from './useImUnread';
@@ -67,6 +68,25 @@ export function onImMessagesUpdated(listener: ImMessagesUpdatedListener) {
return () => messageUpdatedListeners.delete(listener);
}
type ImChatSocketUiListener = (data: Record<string, any>) => void;
const chatSocketUiListeners = new Set<ImChatSocketUiListener>();
/** IM 聊天 UI 层 WS 分发(避免多 ImChat 实例重复绑定 onWebSocket */
export function onImChatSocketUi(listener: ImChatSocketUiListener) {
chatSocketUiListeners.add(listener);
return () => chatSocketUiListeners.delete(listener);
}
function dispatchImChatSocketUi(data: Record<string, any>) {
chatSocketUiListeners.forEach((listener) => {
try {
listener(data);
} catch (err) {
console.error(err);
}
});
}
type ImChatWindowOpenListener = (open: boolean) => void;
const chatWindowOpenListeners = new Set<ImChatWindowOpenListener>();
@@ -187,11 +207,83 @@ export function clearImCache() {
activeConversationId = '';
activeTargetUserId = '';
imChatWindowOpen = false;
//update-begin---author:xsl ---date:20260528 for【IM聊天】清理群聊未读缓存-----------
groupUnreadCache.clear();
//update-end---author:xsl ---date:20260528 for【IM聊天】清理群聊未读缓存-----------
if (scope) {
sessionCache.remove(`${CACHE_KEY}:${scope}`);
}
}
//update-begin---author:xsl ---date:20260528 for【IM聊天】群聊未读缓存独立于成员缓存支持 WS 实时累加和角标统计-----------
interface ImGroupUnreadEntry {
unreadCount: number;
lastContent?: string;
lastTime?: string;
}
/** key = conversationId */
const groupUnreadCache = new Map<string, ImGroupUnreadEntry>();
//update-begin---author:xsl ---date:20260528 for【IM聊天】响应式群聊未读总数供 ImChat.vue tab 角标实时更新-----------
/** 响应式群聊未读总数(跨组件共享,无需加载群列表即可显示 tab 角标) */
export const reactiveGroupUnreadCount = vueRef(0);
function recalcGroupUnreadCount() {
let total = 0;
for (const entry of groupUnreadCache.values()) {
total += entry.unreadCount;
}
reactiveGroupUnreadCount.value = total;
}
//update-end---author:xsl ---date:20260528 for【IM聊天】响应式群聊未读总数供 ImChat.vue tab 角标实时更新-----------
/** 从接口列表初始化群聊未读缓存(登录预取或 loadGroups 后调用) */
export function initGroupUnreadFromList(
groups: Array<{ conversationId: string; unreadCount?: number; lastContent?: string; lastTime?: string }>,
) {
for (const group of groups || []) {
if (group.conversationId) {
groupUnreadCache.set(group.conversationId, {
unreadCount: group.unreadCount || 0,
lastContent: group.lastContent,
lastTime: group.lastTime,
});
}
}
recalcGroupUnreadCount();
}
/** WS 收到群消息时累加未读 */
export function incrementGroupUnread(conversationId: string, lastContent: string, lastTime: string) {
const current = groupUnreadCache.get(conversationId) || { unreadCount: 0 };
groupUnreadCache.set(conversationId, {
unreadCount: current.unreadCount + 1,
lastContent,
lastTime,
});
recalcGroupUnreadCount();
}
/** 打开群聊会话时清零(已读) */
export function resetGroupUnread(conversationId: string) {
const current = groupUnreadCache.get(conversationId);
if (current) {
groupUnreadCache.set(conversationId, { ...current, unreadCount: 0 });
recalcGroupUnreadCount();
}
}
/** 供 syncImUnreadFromMembers 和 refreshImUnread 合并到总未读统计 */
export function getCachedGroupUnreadItems(): Array<{ id: string; conversationId: string; unreadCount: number }> {
return Array.from(groupUnreadCache.entries()).map(([id, data]) => ({
id,
conversationId: id,
unreadCount: data.unreadCount,
}));
}
//update-end---author:xsl ---date:20260528 for【IM聊天】群聊未读缓存独立于成员缓存支持 WS 实时累加和角标统计-----------
export function getCachedMembers(): ImMemberItem[] | null {
const snap = ensureMemory();
return snap.members.length ? snap.members : null;
@@ -323,6 +415,15 @@ export async function prefetchImChatData(force = false): Promise<void> {
setCachedMembers(members);
syncImUnreadFromMembers(members);
//update-begin---author:xsl ---date:20260528 for【IM聊天】预取群聊未读数使顶部角标包含群聊未读-----------
fetchGroups()
.then((groups: any[]) => {
initGroupUnreadFromList(groups || []);
syncImUnreadFromMembers([...members, ...getCachedGroupUnreadItems()]);
})
.catch(() => {});
//update-end---author:xsl ---date:20260528 for【IM聊天】预取群聊未读数使顶部角标包含群聊未读-----------
const candidates = members
.filter((item) => item.conversationId && (item.unreadCount || item.lastTime))
.sort((a, b) => dayjs(b.lastTime || 0).valueOf() - dayjs(a.lastTime || 0).valueOf())
@@ -342,14 +443,37 @@ export async function prefetchImChatData(force = false): Promise<void> {
}
/** 顶部角标:收到新消息时更新缓存未读数 */
//update-begin---author:xsl ---date:20260528 for【IM聊天】按 messageId 去重,防止多个 WS 监听者重复调用导致角标翻倍-----------
const _processedMsgIds = new Set<string>();
//update-end---author:xsl ---date:20260528 for【IM聊天】按 messageId 去重,防止多个 WS 监听者重复调用导致角标翻倍-----------
export function handleImChatSocket(data: Record<string, any>) {
if (data.cmd !== 'chat') {
return;
}
//update-begin---author:xsl ---date:20260528 for【IM聊天】同一消息去重处理避免多监听器重复累加未读-----------
const msgId = data.messageId as string;
if (msgId) {
if (_processedMsgIds.has(msgId)) {
return;
}
_processedMsgIds.add(msgId);
// 5 秒后清理,防止 Set 无限增长
setTimeout(() => _processedMsgIds.delete(msgId), 5000);
}
//update-end---author:xsl ---date:20260528 for【IM聊天】同一消息去重处理避免多监听器重复累加未读-----------
const conversationId = data.conversationId as string;
const senderId = data.senderId as string;
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】单聊 WS 即时同步缓存并区分群聊-----------
const isGroupMessage = data.convType === 'group';
const isViewingSinglePeer = !!activeTargetUserId && activeTargetUserId === senderId;
const isActiveConversation =
isImChatUiOpen() && !!conversationId && conversationId === activeConversationId;
isImChatUiOpen() &&
!!conversationId &&
(conversationId === activeConversationId || (!isGroupMessage && isViewingSinglePeer));
if (isActiveConversation && conversationId !== activeConversationId) {
activeConversationId = conversationId;
}
if (isActiveConversation) {
const userStore = useUserStoreWithOut();
@@ -365,21 +489,32 @@ export function handleImChatSocket(data: Record<string, any>) {
mine: senderId === currentUserId,
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(
peerUserId,
{
conversationId,
lastContent: formatImMessagePreview(data.content, data.msgType),
lastTime: data.createTime,
unreadCount: 0,
},
{ moveToTop: true },
);
if (isGroupMessage) {
//update-begin---author:xsl ---date:20260528 for【IM聊天】活跃群聊消息到达时合并群聊未读数到角标-----------
syncImUnreadFromMembers([...(getCachedMembers() || []), ...getCachedGroupUnreadItems()]);
//update-end---author:xsl ---date:20260528 for【IM聊天】活跃群聊消息到达时合并群聊未读数到角标-----------
} else {
const members = getCachedMembers() || [];
const peerMember = members.find((item) => item.conversationId === conversationId);
const peerUserId = peerMember?.id || (senderId !== currentUserId ? senderId : activeTargetUserId);
if (peerUserId) {
patchCachedMember(
peerUserId,
{
conversationId,
lastContent: formatImMessagePreview(data.content, data.msgType),
lastTime: data.createTime,
unreadCount: 0,
},
{ moveToTop: true },
);
}
}
} else if (isGroupMessage) {
//update-begin---author:xsl ---date:20260528 for【IM聊天】非活跃群聊收到 WS 消息:累加群聊未读并更新顶部角标-----------
incrementGroupUnread(conversationId, formatImMessagePreview(data.content, data.msgType), data.createTime as string);
syncImUnreadFromMembers([...(getCachedMembers() || []), ...getCachedGroupUnreadItems()]);
//update-end---author:xsl ---date:20260528 for【IM聊天】非活跃群聊收到 WS 消息:累加群聊未读并更新顶部角标-----------
} else {
ensureCachedMember(senderId, {
username: data.senderName as string,
@@ -398,6 +533,10 @@ export function handleImChatSocket(data: Record<string, any>) {
},
{ unreadIncrement: 1 },
);
//update-begin---author:xsl ---date:20260528 for【IM聊天】单聊未读角标也合并群聊未读保持角标一致性-----------
syncImUnreadFromMembers([...(getCachedMembers() || []), ...getCachedGroupUnreadItems()]);
//update-end---author:xsl ---date:20260528 for【IM聊天】单聊未读角标也合并群聊未读保持角标一致性-----------
}
syncImUnreadFromMembers(getCachedMembers() || []);
dispatchImChatSocketUi(data);
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】单聊 WS 即时同步缓存并区分群聊-----------
}

View File

@@ -28,6 +28,22 @@ export function useImPageContext() {
type ImOpenTargetListener = (targetUserId: string) => void | Promise<void>;
const openTargetListeners = new Set<ImOpenTargetListener>();
//update-begin---author:xsl ---date:20260528 for【IM聊天】从消息提醒直接打开群聊会话-----------
type ImOpenGroupListener = (conversationId: string) => void | Promise<void>;
const openGroupListeners = new Set<ImOpenGroupListener>();
export function onImOpenGroupRequest(listener: ImOpenGroupListener) {
openGroupListeners.add(listener);
return () => openGroupListeners.delete(listener);
}
async function notifyOpenGroup(conversationId: string) {
for (const listener of openGroupListeners) {
await listener(conversationId);
}
}
//update-end---author:xsl ---date:20260528 for【IM聊天】从消息提醒直接打开群聊会话-----------
export function setImChatPageActive(active: boolean) {
imChatPageActive.value = active;
}
@@ -78,6 +94,10 @@ export function requestCloseImChatModal() {
*/
export async function openImChat(options?: {
targetUserId?: string;
//update-begin---author:xsl ---date:20260528 for【IM聊天】支持从消息提醒直接打开群聊-----------
/** 群聊会话 ID与 targetUserId 二选一) */
conversationId?: string;
//update-end---author:xsl ---date:20260528 for【IM聊天】支持从消息提醒直接打开群聊-----------
pageContext?: ImPageContext | null;
}): Promise<'page' | 'modal'> {
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】弹窗打开时快照背后功能页名称-----------
@@ -88,6 +108,10 @@ export async function openImChat(options?: {
if (isImChatPageActive()) {
if (options?.targetUserId) {
await notifyOpenTarget(options.targetUserId);
//update-begin---author:xsl ---date:20260528 for【IM聊天】全页 IM 时直接导航到指定群聊-----------
} else if (options?.conversationId) {
await notifyOpenGroup(options.conversationId);
//update-end---author:xsl ---date:20260528 for【IM聊天】全页 IM 时直接导航到指定群聊-----------
}
return 'page';
}

View File

@@ -2,6 +2,7 @@ import { ref } from 'vue';
import { fetchDeptMembers } from './im.api';
import {
getCachedMembers,
getCachedGroupUnreadItems,
getImActiveConversationId,
getImActiveTargetUserId,
isImChatUiOpen,
@@ -62,7 +63,9 @@ export async function refreshImUnread(force = false) {
if (!force) {
const cached = getCachedMembers();
if (cached && !isMembersCacheStale()) {
syncImUnreadFromMembers(cached);
//update-begin---author:xsl ---date:20260528 for【IM聊天】从缓存刷新时也合并群聊未读-----------
syncImUnreadFromMembers([...cached, ...getCachedGroupUnreadItems()]);
//update-end---author:xsl ---date:20260528 for【IM聊天】从缓存刷新时也合并群聊未读-----------
return;
}
}
@@ -70,7 +73,9 @@ export async function refreshImUnread(force = false) {
try {
const members = ((await fetchDeptMembers()) || []) as Array<{ unreadCount?: number; conversationId?: string; id?: string }>;
setCachedMembers(members);
syncImUnreadFromMembers(members);
//update-begin---author:xsl ---date:20260528 for【IM聊天】接口刷新时也合并群聊未读-----------
syncImUnreadFromMembers([...members, ...getCachedGroupUnreadItems()]);
//update-end---author:xsl ---date:20260528 for【IM聊天】接口刷新时也合并群聊未读-----------
} finally {
refreshing = false;
}

View File

@@ -9,7 +9,7 @@
<a-list-item-meta>
<template #title>
<div class="im-chat-msg-title">
<span class="im-chat-msg-name">{{ item.realname || item.username }}</span>
<span class="im-chat-msg-name">{{ item.displayName }}</span>
<a-badge :count="item.unreadCount" :overflow-count="99" />
</div>
</template>
@@ -18,9 +18,14 @@
</template>
<template #avatar>
<a-badge dot :offset="[-2, 2]">
<a-avatar :src="getAvatarUrl(item.avatar)">
{{ (item.realname || item.username || '?').slice(0, 1) }}
<!--update-begin---author:xsl ---date:20260528 forIM聊天群聊显示群图标单聊显示头像------------->
<a-avatar v-if="item.type === 'group'" :style="{ backgroundColor: '#1677ff' }">
<Icon icon="ant-design:team-outlined" />
</a-avatar>
<a-avatar v-else :src="getAvatarUrl(item.avatar)">
{{ (item.displayName || '?').slice(0, 1) }}
</a-avatar>
<!--update-end---author:xsl ---date:20260528 forIM聊天群聊显示群图标单聊显示头像----------->
</a-badge>
</template>
</a-list-item-meta>
@@ -37,11 +42,13 @@
import dayjs from 'dayjs';
import { useModal } from '/@/components/Modal';
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
import { fetchDeptMembers } from '/@/views/system/im/im.api';
import { fetchDeptMembers, fetchGroups } from '/@/views/system/im/im.api';
import { formatImMessagePreview } from '/@/views/system/im/imMessageUtil';
import { syncImUnreadFromMembers } from '/@/views/system/im/useImUnread';
import { getCachedGroupUnreadItems, initGroupUnreadFromList } from '/@/views/system/im/imCache';
import ImChatModal from '/@/views/system/im/ImChatModal.vue';
import { openImChat } from '/@/views/system/im/imSession';
import { Icon } from '/@/components/Icon';
defineOptions({ name: 'SysImChatMessageList' });
@@ -49,26 +56,59 @@
(e: 'closeModal'): void;
}>();
interface ChatMemberItem {
//update-begin---author:xsl ---date:20260528 for【IM聊天】统一单聊与群聊展示类型-----------
type ItemType = 'single' | 'group';
interface UnreadItem {
type: ItemType;
/** 单聊userId群聊conversationId */
id: string;
username: string;
realname?: string;
displayName: string;
avatar?: string;
conversationId?: string;
lastContent?: string;
lastTime?: string;
unreadCount?: number;
}
//update-end---author:xsl ---date:20260528 for【IM聊天】统一单聊与群聊展示类型-----------
const loading = ref(false);
const members = ref<ChatMemberItem[]>([]);
const members = ref<any[]>([]);
const groups = ref<any[]>([]);
const [registerImChatModal, { openModal: openImChatModal }] = useModal();
const unreadList = computed(() =>
members.value
//update-begin---author:xsl ---date:20260528 for【IM聊天】合并单聊和群聊未读列表-----------
const unreadList = computed<UnreadItem[]>(() => {
const singleItems: UnreadItem[] = members.value
.filter((item) => (item.unreadCount || 0) > 0)
.sort((a, b) => dayjs(b.lastTime || 0).valueOf() - dayjs(a.lastTime || 0).valueOf()),
);
.map((item) => ({
type: 'single',
id: item.id,
displayName: item.realname || item.username || '',
avatar: item.avatar,
conversationId: item.conversationId,
lastContent: item.lastContent,
lastTime: item.lastTime,
unreadCount: item.unreadCount,
}));
const groupItems: UnreadItem[] = groups.value
.filter((item) => (item.unreadCount || 0) > 0)
.map((item) => ({
type: 'group',
id: item.conversationId,
displayName: item.groupName || '群聊',
conversationId: item.conversationId,
lastContent: item.lastContent,
lastTime: item.lastTime,
unreadCount: item.unreadCount,
}));
return [...singleItems, ...groupItems].sort(
(a, b) => dayjs(b.lastTime || 0).valueOf() - dayjs(a.lastTime || 0).valueOf(),
);
});
//update-end---author:xsl ---date:20260528 for【IM聊天】合并单聊和群聊未读列表-----------
const locale = computed(() => ({
emptyText: loading.value ? ' ' : '暂无未读聊天消息',
@@ -96,36 +136,52 @@
return d.format('MM-DD HH:mm');
}
//update-begin---author:xsl ---date:20260528 for【IM聊天】同时拉取单聊成员和群聊列表修复 syncImUnreadFromMembers 丢失群聊未读-----------
async function reload(silent = false) {
//update-begin---author:cursor ---date:20250528 for【IM聊天-OA】聊天消息列表静默刷新避免 spin 遮罩闪烁-----------
const showLoading = !silent && members.value.length === 0;
const showLoading = !silent && members.value.length === 0 && groups.value.length === 0;
if (showLoading) {
loading.value = true;
}
try {
members.value = ((await fetchDeptMembers()) || []) as ChatMemberItem[];
syncImUnreadFromMembers(members.value);
const [fetchedMembers, fetchedGroups] = await Promise.all([
fetchDeptMembers().catch(() => []),
fetchGroups().catch(() => []),
]);
members.value = (fetchedMembers || []) as any[];
groups.value = (fetchedGroups || []) as any[];
// 同步群聊未读到全局缓存
initGroupUnreadFromList(groups.value);
// 合并单聊 + 群聊未读数,防止 syncImUnreadFromMembers 只传成员数据导致群聊角标清零
syncImUnreadFromMembers([...members.value, ...getCachedGroupUnreadItems()]);
} catch {
if (!silent) {
members.value = [];
groups.value = [];
}
} finally {
if (showLoading) {
loading.value = false;
}
}
//update-end---author:cursor ---date:20250528 for【IM聊天-OA】聊天消息列表静默刷新避免 spin 遮罩闪烁-----------
}
//update-end---author:xsl ---date:20260528 for【IM聊天】同时拉取单聊成员和群聊列表修复 syncImUnreadFromMembers 丢失群聊未读-----------
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-begin---author:xsl ---date:20260528 for【IM聊天】点击群聊条目直接打开群聊会话-----------
async function handleOpenChat(item: UnreadItem) {
if (item.type === 'group') {
const mode = await openImChat({ conversationId: item.conversationId });
if (mode === 'modal') {
openImChatModal(true, { conversationId: item.conversationId });
}
} else {
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');
}
//update-end---author:xsl ---date:20260528 for【IM聊天】点击群聊条目直接打开群聊会话-----------
defineExpose({ reload });
</script>

View File

@@ -223,10 +223,19 @@
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-begin---author:cursor ---date:20260529 for【IM聊天-OA】全部消息支持打开群聊会话-----------
if (record.imConvType === 'group') {
const mode = await openImChat({ conversationId: record.imConversationId });
if (mode === 'modal') {
openImChatModal(true, { conversationId: record.imConversationId });
}
} else {
const mode = await openImChat({ targetUserId: record.imTargetUserId, pageContext: null });
if (mode === 'modal') {
openImChatModal(true, { targetUserId: record.imTargetUserId, pageContext: null });
}
}
//update-end---author:cursor ---date:20260529 for【IM聊天-OA】全部消息支持打开群聊会话-----------
//update-end---author:xsl ---date:20260528 for【IM聊天-OA】统一 IM 打开入口,全页优先-----------
emit('close-modal');
return;

View File

@@ -154,7 +154,9 @@
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';
//update-begin---author:xsl ---date:20260528 for【IM聊天】只保留 isImChatUiOpenhandleImChatSocket 已移除(由 notify 统一调用)-----------
import { isImChatUiOpen } from '/@/views/system/im/imCache';
//update-end---author:xsl ---date:20260528 for【IM聊天】只保留 isImChatUiOpenhandleImChatSocket 已移除(由 notify 统一调用)-----------
import calendar from '/@/assets/icons/calendarNotice.png';
import folder from '/@/assets/icons/folderNotice.png';
import system from '/@/assets/icons/systemNotice.png';
@@ -439,8 +441,12 @@
function onModalWebSocket(data) {
if (data.cmd === 'chat') {
handleImChatSocket(data);
//update-begin---author:xsl ---date:20260528 for【IM聊天】SysMessageModal 与 notify/index.vue 同时监听 WShandleImChatSocket 已由 notify 处理,此处只做 UI 刷新-----------
// handleImChatSocket 已在 notify/index.vue 的 onWebSocketMessage 中调用
// 如果在此重复调用incrementGroupUnread 和 dispatchImChatSocketUi 会触发两次
// 导致 patchGroup(unreadIncrement:1) 执行两次,群聊 tab 角标变为 2实际只有 1 条消息)
refreshImUnread(false);
//update-end---author:xsl ---date:20260528 for【IM聊天】SysMessageModal 与 notify/index.vue 同时监听 WShandleImChatSocket 已由 notify 处理,此处只做 UI 刷新-----------
if (isImChatUiOpen()) {
return;
}

View File

@@ -1,5 +1,7 @@
import dayjs, { Dayjs } from 'dayjs';
import { fetchDeptMembers } from '/@/views/system/im/im.api';
//update-begin---author:cursor ---date:20260529 for【IM聊天-OA】全部消息补充群聊提醒-----------
import { fetchDeptMembers, fetchGroups } from '/@/views/system/im/im.api';
//update-end---author:cursor ---date:20260529 for【IM聊天-OA】全部消息补充群聊提醒-----------
import { formatImMessagePreview } from '/@/views/system/im/imMessageUtil';
export const IM_CHAT_BUS_TYPE = 'im_chat';
@@ -27,6 +29,12 @@ export interface ImChatNoticeRecord {
imTargetUsername?: string;
imAvatar?: string;
imUnreadCount?: number;
//update-begin---author:cursor ---date:20260529 for【IM聊天-OA】全部消息补充群聊提醒-----------
/** 会话类型single 单聊 / group 群聊 */
imConvType?: 'single' | 'group';
/** 群聊会话 ID群聊提醒打开会话时使用 */
imConversationId?: string;
//update-end---author:cursor ---date:20260529 for【IM聊天-OA】全部消息补充群聊提醒-----------
}
interface ImContactLike {
@@ -40,6 +48,16 @@ interface ImContactLike {
unreadCount?: number;
}
//update-begin---author:cursor ---date:20260529 for【IM聊天-OA】全部消息补充群聊提醒-----------
interface ImGroupLike {
conversationId: string;
groupName?: string;
lastContent?: string;
lastTime?: string;
unreadCount?: number;
}
//update-end---author:cursor ---date:20260529 for【IM聊天-OA】全部消息补充群聊提醒-----------
/** 标星列表不包含 IM全部消息或未指定类型、聊天类型时合并 IM */
export function shouldIncludeImChatInList(params: ImChatSearchParams) {
if (params.starFlag === '1') {
@@ -142,14 +160,49 @@ export function mapImContactToNotice(contact: ImContactLike): ImChatNoticeRecord
imTargetUsername: contact.username,
imAvatar: contact.avatar,
imUnreadCount: contact.unreadCount || 0,
//update-begin---author:cursor ---date:20260529 for【IM聊天-OA】全部消息补充群聊提醒-----------
imConvType: 'single',
//update-end---author:cursor ---date:20260529 for【IM聊天-OA】全部消息补充群聊提醒-----------
};
}
export function filterImChatNotices(contacts: ImContactLike[], params: ImChatSearchParams) {
let list = (contacts || [])
//update-begin---author:cursor ---date:20260529 for【IM聊天-OA】全部消息补充群聊提醒-----------
/** 将群聊会话映射为聊天提醒记录 */
export function mapImGroupToNotice(group: ImGroupLike): ImChatNoticeRecord | null {
if (!group?.conversationId || !group?.lastTime) {
return null;
}
const name = group.groupName || '群聊';
const preview = formatImMessagePreview(group.lastContent) || '发来一条新消息';
const timeText = dayjs(group.lastTime).isValid() ? dayjs(group.lastTime).format('YYYY-MM-DD HH:mm:ss') : String(group.lastTime);
return {
id: `im_chat_group_${group.conversationId}`,
busType: IM_CHAT_BUS_TYPE,
titile: `${name}${preview}`,
msgContent: preview,
sendTime: timeText,
createTime: timeText,
readFlag: (group.unreadCount || 0) > 0 ? '0' : '1',
noticeType: IM_CHAT_NOTICE_TYPE,
starFlag: '0',
imTargetUserId: '',
imUnreadCount: group.unreadCount || 0,
imConvType: 'group',
imConversationId: group.conversationId,
};
}
export function filterImChatNotices(contacts: ImContactLike[], groups: ImGroupLike[], params: ImChatSearchParams) {
const singleList = (contacts || [])
.map(mapImContactToNotice)
.filter((item): item is ImChatNoticeRecord => !!item)
.filter((item) => isInRange(item.sendTime, params.rangeDateKey, params.rangeDate));
.filter((item): item is ImChatNoticeRecord => !!item);
// 指定发件人筛选时,仅匹配单聊(群聊不属于单个发件人)
const groupList = params.fromUser
? []
: (groups || []).map(mapImGroupToNotice).filter((item): item is ImChatNoticeRecord => !!item);
let list = [...singleList, ...groupList].filter((item) => isInRange(item.sendTime, params.rangeDateKey, params.rangeDate));
if (params.fromUser) {
list = list.filter((item) => item.imTargetUsername === params.fromUser || item.imTargetUserId === params.fromUser);
@@ -159,9 +212,13 @@ export function filterImChatNotices(contacts: ImContactLike[], params: ImChatSea
}
export async function fetchImChatNoticeList(params: ImChatSearchParams) {
const contacts = ((await fetchDeptMembers()) || []) as ImContactLike[];
return filterImChatNotices(contacts, params);
const [contacts, groups] = (await Promise.all([
Promise.resolve(fetchDeptMembers()).catch(() => []),
Promise.resolve(fetchGroups()).catch(() => []),
])) as [ImContactLike[], ImGroupLike[]];
return filterImChatNotices(contacts || [], groups || [], params);
}
//update-end---author:cursor ---date:20260529 for【IM聊天-OA】全部消息补充群聊提醒-----------
export function mergeMessageList(systemList: any[] = [], imList: ImChatNoticeRecord[] = []) {
if (!imList.length) {