feat(system): 添加公告通知已读记录功能
- 新增 SysNoticeRead 实体类用于存储公告/通知已读记录 - 实现 SysNoticeReadMapper 数据访问层接口及 XML 映射文件 - 创建 ISysNoticeReadService 服务接口及实现类 - 添加数据库表 sys_notice_read 存储用户阅读状态 - 添加发布状态字段到公告表支持公告发布控制 - 实现前端 NoticePanel 组件支持未读标记和阅读状态显示 - 提供标记已读、批量标记、未读数量统计等功能 - 优化公告列表按已读状态和时间排序显示
This commit is contained in:
249
openhis-ui-vue3/src/components/NoticePanel.vue
Normal file
249
openhis-ui-vue3/src/components/NoticePanel.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<el-drawer v-model="noticeVisible" title="公告" direction="rtl" size="400px" destroy-on-close>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="公告" name="notice">
|
||||
<el-empty v-if="noticeList.length === 0" description="暂无公告" />
|
||||
<div v-else class="notice-list">
|
||||
<div v-for="item in noticeList" :key="item.noticeId" class="notice-item" :class="{ 'is-read': isRead(item.noticeId) }" @click="viewDetail(item)">
|
||||
<div class="notice-title">
|
||||
<span v-if="!isRead(item.noticeId)" class="unread-dot"></span>
|
||||
{{ item.noticeTitle }}
|
||||
</div>
|
||||
<div class="notice-info">
|
||||
<span class="notice-type">
|
||||
<dict-tag :options="sys_notice_type" :value="item.noticeType" />
|
||||
</span>
|
||||
<span class="notice-time">{{ parseTime(item.createTime, '{y}-{m}-{d}') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="通知" name="notification">
|
||||
<el-empty v-if="notificationList.length === 0" description="暂无通知" />
|
||||
<div v-else class="notice-list">
|
||||
<div v-for="item in notificationList" :key="item.noticeId" class="notice-item" :class="{ 'is-read': isRead(item.noticeId) }" @click="viewDetail(item)">
|
||||
<div class="notice-title">
|
||||
<span v-if="!isRead(item.noticeId)" class="unread-dot"></span>
|
||||
{{ item.noticeTitle }}
|
||||
</div>
|
||||
<div class="notice-info">
|
||||
<span class="notice-type">
|
||||
<dict-tag :options="sys_notice_type" :value="item.noticeType" />
|
||||
</span>
|
||||
<span class="notice-time">{{ parseTime(item.createTime, '{y}-{m}-{d}') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 公告/通知详情对话框 -->
|
||||
<el-dialog v-model="detailVisible" :title="currentNotice.noticeTitle" width="800px" append-to-body>
|
||||
<div class="notice-detail">
|
||||
<div class="detail-header">
|
||||
<span class="detail-type">
|
||||
<dict-tag :options="sys_notice_type" :value="currentNotice.noticeType" />
|
||||
</span>
|
||||
<span class="detail-time">{{ parseTime(currentNotice.createTime, '{y}-{m}-{d} {h}:{i}') }}</span>
|
||||
</div>
|
||||
<div class="detail-content" v-html="currentNotice.noticeContent"></div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { getPublicNoticeList, getUserNotices, markAsRead, getReadNoticeIds } from '@/api/system/notice'
|
||||
import useUserStore from '@/store/modules/user'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const { sys_notice_type } = proxy.useDict('sys_notice_type')
|
||||
const emit = defineEmits(['updateUnreadCount'])
|
||||
const userStore = useUserStore()
|
||||
|
||||
const noticeVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const activeTab = ref('notice')
|
||||
const noticeList = ref([])
|
||||
const notificationList = ref([])
|
||||
const currentNotice = ref({})
|
||||
const readNoticeIds = ref(new Set())
|
||||
|
||||
// 打开公告/通知面板
|
||||
function open() {
|
||||
noticeVisible.value = true
|
||||
loadNotices()
|
||||
loadReadNoticeIds()
|
||||
}
|
||||
|
||||
// 加载已读公告ID列表
|
||||
function loadReadNoticeIds() {
|
||||
getReadNoticeIds().then(response => {
|
||||
const ids = response.data || []
|
||||
readNoticeIds.value = new Set(ids)
|
||||
// 同步到 localStorage
|
||||
localStorage.setItem('readNoticeIds', JSON.stringify(ids))
|
||||
}).catch(() => {
|
||||
// 接口调用失败时从 localStorage 读取
|
||||
const readIds = localStorage.getItem('readNoticeIds')
|
||||
if (readIds) {
|
||||
try {
|
||||
const ids = JSON.parse(readIds)
|
||||
readNoticeIds.value = new Set(ids)
|
||||
} catch (e) {
|
||||
console.error('解析已读ID失败', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 排序:未读的排前面,已读的排后面,同类型按时间倒序
|
||||
function sortNoticeList(list) {
|
||||
return list.sort((a, b) => {
|
||||
const aRead = isRead(a.noticeId)
|
||||
const bRead = isRead(b.noticeId)
|
||||
|
||||
// 未读排在前面
|
||||
if (aRead !== bRead) {
|
||||
return aRead ? 1 : -1
|
||||
}
|
||||
|
||||
// 同类型按创建时间倒序(最新的在前)
|
||||
return new Date(b.createTime) - new Date(a.createTime)
|
||||
})
|
||||
}
|
||||
|
||||
// 加载公告和通知
|
||||
function loadNotices() {
|
||||
// 加载公告列表
|
||||
getPublicNoticeList({ pageNum: 1, pageSize: 10 }).then(response => {
|
||||
let list = response.rows || response.data || []
|
||||
noticeList.value = sortNoticeList(list)
|
||||
})
|
||||
|
||||
// 加载通知列表
|
||||
getUserNotices().then(response => {
|
||||
let list = response.data || []
|
||||
notificationList.value = sortNoticeList(list)
|
||||
})
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
function viewDetail(item) {
|
||||
currentNotice.value = item
|
||||
detailVisible.value = true
|
||||
|
||||
// 标记为已读
|
||||
if (!readNoticeIds.value.has(item.noticeId)) {
|
||||
markAsRead(item.noticeId).then(() => {
|
||||
readNoticeIds.value.add(item.noticeId)
|
||||
// 保存到 localStorage
|
||||
saveReadNoticeIds()
|
||||
emit('updateUnreadCount')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 保存已读公告ID列表
|
||||
function saveReadNoticeIds() {
|
||||
const ids = Array.from(readNoticeIds.value)
|
||||
localStorage.setItem('readNoticeIds', JSON.stringify(ids))
|
||||
}
|
||||
|
||||
// 检查是否已读
|
||||
function isRead(noticeId) {
|
||||
return readNoticeIds.value.has(noticeId)
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
open,
|
||||
isRead,
|
||||
readNoticeIds
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notice-list {
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notice-item {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #EBEEF5;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #F5F7FA;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.is-read {
|
||||
.notice-title {
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.unread-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: #f56c6c;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #EBEEF5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-time {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #303133;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-drawer__body) {
|
||||
padding: 0 20px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user