站内消息.

This commit is contained in:
lijiahang
2024-05-14 15:37:50 +08:00
parent e86bf3f19d
commit a0717c3338
8 changed files with 567 additions and 234 deletions

View File

@@ -21,20 +21,21 @@ export interface MessageRecordResponse {
relKey: string;
title: string;
content: string;
contentHtml: string;
createTime: number;
}
/**
* 查询系统消息列表
*/
export function getMessageList(request: MessageQueryRequest) {
export function getSystemMessageList(request: MessageQueryRequest) {
return axios.post<Array<MessageRecordResponse>>('/infra/system-message/list', request);
}
/**
* 查询系统消息数量
*/
export function getMessageCount(queryUnread: boolean) {
export function getSystemMessageCount(queryUnread: boolean) {
return axios.get<Record<string, number>>('/infra/system-message/count', { params: { queryUnread } });
}
@@ -48,27 +49,27 @@ export function checkHasUnreadMessage() {
/**
* 更新系统消息为已读
*/
export function updateMessageRead(id: number) {
export function updateSystemMessageRead(id: number) {
return axios.put('/infra/system-message/read', undefined, { params: { id } });
}
/**
* 更新全部系统消息为已读
*/
export function updateMessageReadAll(classify: string) {
export function updateSystemMessageReadAll(classify: string) {
return axios.put('/infra/system-message/read-all', undefined, { params: { classify } });
}
/**
* 删除系统消息
*/
export function deleteMessage(id: number) {
export function deleteSystemMessage(id: number) {
return axios.delete('/infra/system-message/delete', { params: { id } });
}
/**
* 清理已读的系统消息
*/
export function clearMessage(classify: string) {
export function clearSystemMessage(classify: string) {
return axios.delete('/infra/system-message/clear', { params: { classify } });
}

View File

@@ -80,11 +80,11 @@
</a-button>
</a-tooltip>
</li>
<!-- 消息列表 -->
<li v-if="false">
<a-tooltip content="消息通知">
<!-- 系统消息 -->
<li>
<a-tooltip content="系统消息" :show-arrow="false">
<div class="message-box-trigger">
<a-badge :count="9" dot>
<a-badge :count="messageCount" dot>
<a-button class="nav-btn"
type="outline"
shape="circle"
@@ -95,9 +95,11 @@
</div>
</a-tooltip>
<a-popover trigger="click"
:arrow-style="{ display: 'none' }"
:content-style="{ padding: 0, minWidth: '400px' }"
content-class="message-popover">
content-class="message-popover"
position="br"
:show-arrow="false"
:popup-style="{ marginLeft: '198px' }"
:content-style="{ padding: 0, width: '498px' }">
<div ref="messageRef" class="ref-btn" />
<template #content>
<message-box />
@@ -202,7 +204,7 @@
</template>
<script lang="ts" setup>
import { computed, inject, ref } from 'vue';
import { computed, inject, onMounted, onUnmounted, ref } from 'vue';
import useLocale from '@/hooks/locale';
import useUser from '@/hooks/user';
import { useRoute, useRouter } from 'vue-router';
@@ -214,6 +216,7 @@
import { preferenceTipsKey } from './const';
import { REDIRECT_ROUTE_NAME, routerToTag } from '@/router/constants';
import { openNewRoute } from '@/router';
import { checkHasUnreadMessage } from '@/api/system/message';
import SystemMenuTree from '@/components/system/menu/tree/index.vue';
import MessageBox from '@/components/system/message-box/index.vue';
import UpdatePasswordModal from '@/components/user/user/update-password-modal/index.vue';
@@ -258,6 +261,9 @@
const messageRef = ref();
// 语言
const localeRef = ref();
// 消息数量
const messageCount = ref(0);
const messageIntervalId = ref();
// 打开应用设置
const openAppSetting = inject(openAppSettingKey) as () => void;
@@ -302,6 +308,18 @@
await logout();
};
// 获取是否有未读的消息
const pullHasUnreadMessage = () => {
// 有未读的消息直接返回
if (messageCount.value) {
return;
}
// 查询
checkHasUnreadMessage().then(({ data }) => {
messageCount.value = data ? 1 : 0;
});
};
// 关闭偏好提示
const closePreferenceTip = (ack: boolean) => {
tippedPreference.value = false;
@@ -310,6 +328,18 @@
}
};
onMounted(() => {
// 查询未读消息
pullHasUnreadMessage();
// 注册未读消息轮询
messageIntervalId.value = setInterval(pullHasUnreadMessage, 30000);
});
onUnmounted(() => {
// 清理消息轮询
clearInterval(messageIntervalId.value);
});
</script>
<style lang="less" scoped>

View File

@@ -0,0 +1,20 @@
// 消息状态
export const MessageStatus = {
UNREAD: 0,
READ: 1,
};
// 查询数量
export const messageLimit = 15;
// 默认消息分类 通知
export const defaultClassify = 'NOTICE';
// 消息分类 字典项
export const messageClassifyKey = 'messageClassify';
// 消息类型 字典项
export const messageTypeKey = 'messageType';
// 加载的字典值
export const dictKeys = [messageClassifyKey, messageTypeKey];

View File

@@ -1,108 +1,246 @@
<template>
<a-spin style="display: block" :loading="loading">
<a-tabs v-model:activeKey="messageType" type="rounded" destroy-on-hide>
<a-tab-pane v-for="item in tabList" :key="item.key">
<template #title>
<span> {{ item.title }}{{ formatUnreadLength(item.key) }} </span>
<div class="full">
<!-- 消息分类 -->
<a-spin class="message-classify-container"
:hide-icon="true"
:loading="fetchLoading">
<a-tabs v-model:activeKey="currentClassify"
type="rounded"
:hide-content="true"
@change="loadClassifyMessage">
<!-- 消息列表 -->
<a-tab-pane v-for="item in toOptions(messageClassifyKey)"
:key="item.value">
<!-- 标题 -->
<template #title>
<span class="usn">{{ item.label }} ({{ classifyCount[item.value] || 0 }})</span>
</template>
<!-- 消息列表 -->
</a-tab-pane>
<!-- 右侧操作 -->
<template #extra>
<a-space>
<!-- 状态 -->
<a-switch v-model="queryUnread"
type="round"
checked-text="未读"
unchecked-text="全部"
@change="changeMessageStatus" />
<!-- 全部已读 -->
<a-button class="header-button"
type="text"
size="small"
@click="setAllRead">
全部已读
</a-button>
<!-- 清空 -->
<a-button class="header-button"
type="text"
size="small"
@click="clearAllMessage">
清空
</a-button>
</a-space>
</template>
<a-result v-if="!renderList.length" status="404">
<template #subtitle>暂无内容</template>
</a-result>
<list :render-list="renderList"
:unread-count="unreadCount"
@item-click="handleItemClick" />
</a-tab-pane>
<template #extra>
<a-button type="text" @click="emptyList">
清空
</a-button>
</template>
</a-tabs>
</a-spin>
</a-tabs>
</a-spin>
<!-- 消息列表 -->
<list :fetch-loading="fetchLoading"
:message-loading="messageLoading"
:has-more="hasMore"
:message-list="messageList"
@load="loadMessage"
@click="clickMessage"
@view="viewMessage"
@delete="deleteMessage" />
<!-- 模态框 -->
<modal ref="modalRef"
@delete="deleteMessage" />
</div>
</template>
<script lang="ts">
export default {
name: 'messageBox'
};
</script>
<script lang="ts" setup>
import type { MessageRecord, MessageListType } from '@/api/system/message';
import { ref, reactive, toRefs, computed } from 'vue';
import { queryMessageList, setMessageStatus } from '@/api/system/message';
import type { MessageRecordResponse } from '@/api/system/message';
import { ref, onMounted } from 'vue';
import {
clearSystemMessage,
deleteSystemMessage,
getSystemMessageCount,
getSystemMessageList,
updateSystemMessageRead,
updateSystemMessageReadAll
} from '@/api/system/message';
import useLoading from '@/hooks/loading';
import { useRouter } from 'vue-router';
import { useDictStore } from '@/store';
import { dictKeys, messageClassifyKey, messageTypeKey, defaultClassify, messageLimit, MessageStatus } from './const';
import List from './list.vue';
import Modal from './modal.vue';
import { clearHtmlTag, replaceHtmlTag } from '@/utils';
interface TabItem {
key: string;
title: string;
avatar?: string;
}
const { loading: fetchLoading, setLoading: setFetchLoading } = useLoading();
const { loading: messageLoading, setLoading: setMessageLoading } = useLoading();
const { loadKeys, toOptions, getDictValue } = useDictStore();
const router = useRouter();
const { loading, setLoading } = useLoading(true);
const currentClassify = ref(defaultClassify);
const queryUnread = ref(false);
const classifyCount = ref<Record<string, number>>({});
const messageList = ref<Array<MessageRecordResponse>>([]);
const hasMore = ref(true);
const modalRef = ref();
const messageType = ref('message');
const messageData = reactive<{
renderList: MessageRecord[];
messageList: MessageRecord[];
}>({
renderList: [],
messageList: [],
});
toRefs(messageData);
const tabList: TabItem[] = [
{
key: 'message',
title: '消息',
},
{
key: 'notice',
title: '通知',
},
{
key: 'todo',
title: '待办',
},
];
// 修改消息状态
const changeMessageStatus = async () => {
hasMore.value = true;
messageList.value = [];
// 查询数量
queryMessageCount();
// 加载列表
await loadMessage();
};
async function fetchSourceData() {
setLoading(true);
// 获取数量
const queryMessageCount = async () => {
setFetchLoading(true);
try {
const { data } = await queryMessageList();
messageData.messageList = data;
} catch (err) {
// you can report use errorHandler or other
const { data } = await getSystemMessageCount(queryUnread.value);
classifyCount.value = data;
} catch (ex) {
} finally {
setLoading(false);
setFetchLoading(false);
}
}
};
async function readMessage(data: MessageListType) {
const ids = data.map((item) => item.id);
await setMessageStatus({ ids });
await fetchSourceData();
}
// 查询分类消息
const loadClassifyMessage = async () => {
hasMore.value = true;
messageList.value = [];
await loadMessage();
};
const renderList = computed(() => {
return messageData.messageList.filter(
(item) => messageType.value === item.type
);
// 加载消息
const loadMessage = async () => {
hasMore.value = true;
setFetchLoading(true);
try {
const maxId = messageList.value.length
? messageList.value[messageList.value.length - 1].id
: undefined;
// 查询数据
const { data } = await getSystemMessageList({
limit: messageLimit,
classify: currentClassify.value,
queryUnread: queryUnread.value,
maxId,
});
data.forEach(s => {
messageList.value.push({
...s,
content: clearHtmlTag(s.content),
contentHtml: replaceHtmlTag(s.content),
});
});
hasMore.value = data.length === messageLimit;
} catch (ex) {
} finally {
setFetchLoading(false);
}
};
// 设置全部已读
const setAllRead = async () => {
setMessageLoading(true);
try {
// 设置为已读
await updateSystemMessageReadAll(currentClassify.value);
// 修改状态
messageList.value.forEach(s => s.status = MessageStatus.READ);
} catch (ex) {
} finally {
setMessageLoading(false);
}
};
// 清理已读消息
const clearAllMessage = async () => {
setMessageLoading(true);
try {
// 清理消息
await clearSystemMessage(currentClassify.value);
} catch (ex) {
} finally {
setMessageLoading(false);
}
// 查询消息
await changeMessageStatus();
};
// 点击消息
const clickMessage = (message: MessageRecordResponse) => {
// 设置为已读
if (message.status === MessageStatus.UNREAD) {
updateSystemMessageRead(message.id);
message.status = MessageStatus.READ;
}
const redirectComponent = getDictValue(messageTypeKey, message.type, 'redirectComponent');
if (redirectComponent && redirectComponent !== '0') {
// 跳转组件
router.push({ name: redirectComponent, query: { key: message.relKey } });
} else {
// 打开消息模态框
modalRef.value.open(message);
}
};
// 查看消息
const viewMessage = async (message: MessageRecordResponse) => {
setMessageLoading(true);
try {
// 设置为已读
if (message.status === MessageStatus.UNREAD) {
await updateSystemMessageRead(message.id);
message.status = MessageStatus.READ;
}
// 打开消息模态框
modalRef.value.open(message);
} catch (ex) {
} finally {
setMessageLoading(false);
}
};
// 删除消息
const deleteMessage = async (message: MessageRecordResponse) => {
setMessageLoading(true);
try {
// 删除消息
await deleteSystemMessage(message.id);
// 减少数量
classifyCount.value[currentClassify.value] -= 1;
// 移除
const index = messageList.value.findIndex(s => s.id === message.id);
messageList.value.splice(index, 1);
} catch (ex) {
} finally {
setMessageLoading(false);
}
};
// 加载字典值
onMounted(() => {
loadKeys(dictKeys);
});
const unreadCount = computed(() => {
return renderList.value.filter((item) => !item.status).length;
});
const getUnreadList = (type: string) => {
const list = messageData.messageList.filter(
(item) => item.type === type && !item.status
);
return list;
};
const formatUnreadLength = (type: string) => {
const list = getUnreadList(type);
return list.length ? `(${list.length})` : '';
};
const handleItemClick = (items: MessageListType) => {
if (renderList.value.length) readMessage([...items]);
};
const emptyList = () => {
messageData.messageList = [];
};
fetchSourceData();
// 获取消息
onMounted(changeMessageStatus);
</script>
<style lang="less" scoped>
@@ -110,20 +248,23 @@
padding: 0;
}
:deep(.arco-list-item-meta) {
align-items: flex-start;
}
:deep(.arco-tabs-nav) {
padding: 14px 0 12px 16px;
padding: 10px 12px;
border-bottom: 1px solid var(--color-neutral-3);
}
:deep(.arco-tabs-content) {
padding-top: 0;
}
.arco-result-subtitle {
color: rgb(var(--gray-6));
.message-classify-container {
width: 100%;
height: 100%;
display: block;
.header-button {
padding: 0 6px;
}
}
</style>

View File

@@ -1,149 +1,216 @@
<template>
<a-list :bordered="false">
<a-list-item v-for="item in renderList"
:key="item.id"
action-layout="vertical"
:style="{
opacity: item.status ? 0.5 : 1,
}">
<template #extra>
<a-tag v-if="item.messageType === 0" color="gray">未开始</a-tag>
<a-tag v-else-if="item.messageType === 1" color="green">已开通</a-tag>
<a-tag v-else-if="item.messageType === 2" color="blue">进行中</a-tag>
<a-tag v-else-if="item.messageType === 3" color="red">即将到期</a-tag>
</template>
<div class="item-wrap" @click="onItemClick(item)">
<a-list-item-meta>
<template v-if="item.avatar" #avatar>
<a-avatar shape="circle">
<img v-if="item.avatar" :src="item.avatar" />
<icon-desktop v-else />
</a-avatar>
</template>
<template #title>
<a-space :size="4">
<span>{{ item.title }}</span>
<a-typography-text type="secondary">
{{ item.subTitle }}
</a-typography-text>
</a-space>
</template>
<template #description>
<div>
<a-typography-paragraph :ellipsis="{
rows: 1,
}">
{{ item.content }}
</a-typography-paragraph>
<a-typography-text v-if="item.type === 'message'"
class="time-text">
{{ item.time }}
</a-typography-text>
</div>
</template>
</a-list-item-meta>
</div>
</a-list-item>
<template #footer>
<a-space fill
:size="0"
:class="{ 'add-border-top': renderList.length < showMax }">
<div class="footer-wrap">
<a-link @click="allRead">全部已读</a-link>
</div>
<div class="footer-wrap">
<a-link>查看更多</a-link>
</div>
</a-space>
</template>
<div v-if="renderList.length && renderList.length < 3"
:style="{ height: (showMax - renderList.length) * 86 + 'px' }">
<!-- 消息列表 -->
<a-spin class="message-list-container" :loading="messageLoading">
<!-- 加载中 -->
<div v-if="!messageList.length && fetchLoading">
<!-- 加载中 -->
<a-skeleton class="skeleton-wrapper" :animation="true">
<a-skeleton-line :rows="3"
:line-height="86"
:line-spacing="8" />
</a-skeleton>
</div>
</a-list>
<!-- 无数据 -->
<div v-else-if="!messageList.length && !fetchLoading">
<a-result status="404">
<template #subtitle>暂无内容</template>
</a-result>
</div>
<!-- 消息容器 -->
<div v-else class="message-list-wrapper">
<a-scrollbar style="overflow-y: auto; height: 100%;">
<!-- 消息列表-->
<div v-for="message in messageList"
class="message-item"
:class="[ message.status === MessageStatus.READ ? 'message-item-read' : 'message-item-unread' ]"
@click="emits('click', message)">
<!-- 标题 -->
<div class="message-item-title">
<!-- 标题 -->
<div class="message-item-title-text text-ellipsis" :title="message.title">
{{ message.title }}
</div>
<!-- tag -->
<div class="message-item-title-status">
<template v-if="getDictValue(messageTypeKey, message.type, 'tagVisible', false)">
<a-tag size="mini" :color="getDictValue(messageTypeKey, message.type, 'tagColor')">
{{ getDictValue(messageTypeKey, message.type, 'tagLabel') }}
</a-tag>
</template>
</div>
<!-- 操作 -->
<div class="message-item-title-actions">
<!-- 查看 -->
<a-button class="mr4"
size="mini"
type="text"
@click.stop="emits('view', message)">
查看
</a-button>
<!-- 删除 -->
<a-button size="mini"
type="text"
status="danger"
@click.stop="emits('delete', message)">
删除
</a-button>
</div>
</div>
<!-- 文本 -->
<div v-html="message.contentHtml"
class="message-item-content text-ellipsis"
:title="message.content" />
</div>
<!-- 加载中 -->
<a-skeleton v-if="fetchLoading"
class="skeleton-wrapper"
:animation="true">
<a-skeleton-line :rows="3"
:line-height="86"
:line-spacing="8" />
</a-skeleton>
<!-- 加载更多 -->
<div v-if="hasMore" class="load-more-wrapper">
<a-button size="small"
:fetchLoading="fetchLoading"
@click="() => emits('load')">
加载更多
</a-button>
</div>
</a-scrollbar>
</div>
</a-spin>
</template>
<script lang="ts">
export default {
name: 'messageBoxList'
};
</script>
<script lang="ts" setup>
import type { MessageListType, MessageRecord } from '@/api/system/message';
import type { MessageRecordResponse } from '@/api/system/message';
import { MessageStatus, messageTypeKey } from './const';
import { useDictStore } from '@/store';
const props = withDefaults(defineProps<{
renderList: MessageListType;
unreadCount?: number;
}>(), {
unreadCount: 0,
});
const emits = defineEmits(['load', 'click', 'view', 'delete']);
const props = defineProps<{
fetchLoading: boolean;
messageLoading: boolean;
hasMore: boolean;
messageList: Array<MessageRecordResponse>;
}>();
const emit = defineEmits(['itemClick']);
const allRead = () => {
emit('itemClick', [...props.renderList]);
};
const { getDictValue } = useDictStore();
const onItemClick = (item: MessageRecord) => {
if (!item.status) {
emit('itemClick', [item]);
}
};
const showMax = 3;
</script>
<style lang="less" scoped>
:deep(.arco-list) {
.arco-list-item {
min-height: 86px;
border-bottom: 1px solid rgb(var(--gray-3));
@gap: 8px;
@message-height: 86px;
@actions-width: 82px;
.skeleton-wrapper {
padding: 8px 12px 0 12px;
}
.message-list-container {
width: 100%;
height: 344px;
display: block;
.message-list-wrapper {
width: 100%;
height: 100%;
position: relative;
}
.arco-list-item-extra {
position: absolute;
right: 20px;
.load-more-wrapper {
display: flex;
justify-content: center;
margin: 12px 0;
}
}
.message-item {
height: @message-height;
padding: 16px 20px;
border-bottom: 1px solid var(--color-neutral-3);
display: flex;
flex-direction: column;
justify-content: space-between;
font-size: 16px;
color: var(--color-text-1);
cursor: pointer;
&-title {
display: flex;
justify-content: space-between;
align-items: center;
&-text {
width: calc(100% - @actions-width - @gap);
display: block;
font-weight: 600;
text-overflow: clip;
}
&-status {
width: @actions-width;
display: flex;
align-items: flex-start;
justify-content: flex-end;
}
&-actions {
width: @actions-width;
display: none;
justify-content: flex-end;
align-items: flex-end;
button {
padding: 0 6px !important;
&:hover {
background: var(--color-fill-3) !important;
}
}
}
}
.arco-list-item-meta-content {
flex: 1;
&-content {
color: var(--color-text-1);
text-overflow: clip;
}
}
.item-wrap {
cursor: pointer;
}
.message-item:hover {
background: var(--color-fill-1);
.time-text {
font-size: 12px;
color: rgb(var(--gray-6));
}
.arco-empty {
.message-item-title-status {
display: none;
}
.arco-list-footer {
padding: 0;
height: 50px;
line-height: 50px;
border-top: none;
.arco-space-item {
width: 100%;
border-right: 1px solid rgb(var(--gray-3));
&:last-child {
border-right: none;
}
}
.add-border-top {
border-top: 1px solid rgb(var(--gray-3));
}
}
.footer-wrap {
text-align: center;
}
.arco-typography {
margin-bottom: 0;
}
.add-border {
border-top: 1px solid rgb(var(--gray-3));
.message-item-title-actions {
display: flex;
opacity: 1;
}
}
.message-item-read {
.message-item-title-text, .message-item-title-status, .message-item-content {
opacity: .65;
}
}
:deep(.arco-scrollbar) {
position: absolute;
height: 100%;
width: 100%;
.arco-scrollbar-track-direction-horizontal {
display: none;
}
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<a-modal v-model:visible="visible"
title-align="start"
:title="record.title"
:top="80"
:width="720"
:align-center="false"
:unmount-on-close="true"
ok-text="删除"
:hide-cancel="true"
:ok-button-props="{ status: 'danger' }"
:body-style="{ padding: '20px' }"
@ok="emits('delete', record)">
<div class="content" v-html="record.contentHtml" />
</a-modal>
</template>
<script lang="ts">
export default {
name: 'messageBoxModal'
};
</script>
<script lang="ts" setup>
import type { MessageRecordResponse } from '@/api/system/message';
import useVisible from '@/hooks/visible';
import { ref } from 'vue';
const emits = defineEmits(['delete']);
const { visible, setVisible } = useVisible();
const record = ref<MessageRecordResponse>({} as MessageRecordResponse);
// 打开
const open = (message: MessageRecordResponse) => {
record.value = message;
setVisible(true);
};
defineExpose({ open });
</script>
<style lang="less" scoped>
.content {
font-size: 16px;
}
</style>

View File

@@ -28,10 +28,14 @@
await dictStore.loadKeys(dictKeys);
});
// 跳转日志
onMounted(async () => {
const idParam = route.query.id;
const keyParam = route.query.key;
if (idParam) {
await panel.value?.openLog(Number.parseInt(idParam as string));
} else if (keyParam) {
await panel.value?.openLog(Number.parseInt(keyParam as string));
}
});

View File

@@ -26,10 +26,13 @@
import useVisible from '@/hooks/visible';
import { useDictStore } from '@/store';
import { dictKeys } from '@/components/exec/log/const';
import { useRoute } from 'vue-router';
import { getExecCommandLog } from '@/api/exec/exec-command-log';
import ExecCommandPanel from './components/exec-command-panel.vue';
import ExecLogPanel from '@/components/exec/log/panel/index.vue';
const { visible: logVisible, setVisible: setLogVisible } = useVisible();
const route = useRoute();
const log = ref();
@@ -41,12 +44,30 @@
});
};
// 打开日志
const openLogWithId = async (id: number) => {
setLogVisible(true);
// 查询日志
const { data } = await getExecCommandLog(id);
openLog(data);
};
// 加载字典值
onMounted(async () => {
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
});
// 跳转日志
onMounted(async () => {
const idParam = route.query.id;
const keyParam = route.query.key;
if (idParam) {
await openLogWithId(Number.parseInt(idParam as string));
} else if (keyParam) {
await openLogWithId(Number.parseInt(keyParam as string));
}
});
</script>
<style lang="less" scoped>