- 新增顶部公告/通知列表获取接口 listNoticeTop - 新增标记单条公告为已读接口 markNoticeRead - 新增批量标记公告为已读接口 markNoticeReadAll - 重构 HeaderNotice 组件实现完整的消息中心功能 - 添加标签页分类显示全部、通知、公告三种类型 - 实现消息实时未读数统计和标记已读功能 - 优化消息展示界面增加图标区分通知和公告类型 - 更新 Navbar 组件集成新的消息中心功能 - 调整布局样式适配消息中心组件 - 修复设置面板导航类型配置问题 - 添加 Chrome 风格标签页样式支持
361 lines
8.3 KiB
Vue
361 lines
8.3 KiB
Vue
<template>
|
|
<div>
|
|
<el-popover
|
|
ref="noticePopover"
|
|
placement="bottom-end"
|
|
:width="360"
|
|
trigger="click"
|
|
v-model:visible="noticeVisible"
|
|
popper-class="notice-popover"
|
|
:show-arrow="false"
|
|
>
|
|
<template #default>
|
|
<div class="notice-popover-content">
|
|
<!-- 头部 -->
|
|
<div class="notice-header">
|
|
<span class="notice-header-title">消息中心</span>
|
|
<el-badge :value="noticeStore.unreadCount" :hidden="noticeStore.unreadCount === 0" :max="99">
|
|
<span />
|
|
</el-badge>
|
|
<span class="notice-header-action" @click="handleReadAll">全部已读</span>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<el-tabs v-model="activeTab" class="notice-tabs">
|
|
<el-tab-pane name="all">
|
|
<template #label>
|
|
全部
|
|
<el-badge v-if="allUnread > 0" :value="allUnread" :max="99" class="tab-badge" />
|
|
</template>
|
|
</el-tab-pane>
|
|
<el-tab-pane name="notice">
|
|
<template #label>
|
|
通知
|
|
<el-badge v-if="noticeUnread > 0" :value="noticeUnread" :max="99" class="tab-badge" />
|
|
</template>
|
|
</el-tab-pane>
|
|
<el-tab-pane name="announce">
|
|
<template #label>
|
|
公告
|
|
<el-badge v-if="announceUnread > 0" :value="announceUnread" :max="99" class="tab-badge" />
|
|
</template>
|
|
</el-tab-pane>
|
|
</el-tabs>
|
|
|
|
<!-- 列表 -->
|
|
<el-scrollbar max-height="350px" class="notice-scroll">
|
|
<div v-if="filteredList.length === 0" class="notice-empty">
|
|
<el-empty description="暂无消息" :image-size="60" />
|
|
</div>
|
|
<div
|
|
v-for="item in filteredList"
|
|
:key="item.noticeId"
|
|
class="notice-item"
|
|
:class="{ 'is-read': isRead(item) }"
|
|
@click="handleRead(item)"
|
|
>
|
|
<div class="notice-item-icon">
|
|
<el-icon v-if="item.noticeType === '1'" class="icon-notice"><Bell /></el-icon>
|
|
<el-icon v-else class="icon-announce"><Notification /></el-icon>
|
|
</div>
|
|
<div class="notice-item-body">
|
|
<div class="notice-item-title">
|
|
<span>{{ item.noticeTitle }}</span>
|
|
<el-tag v-if="!isRead(item)" type="danger" size="small" effect="dark" class="unread-tag">未读</el-tag>
|
|
</div>
|
|
<div class="notice-item-time">{{ formatTime(item.createTime) }}</div>
|
|
</div>
|
|
</div>
|
|
</el-scrollbar>
|
|
|
|
<!-- 底部 -->
|
|
<div class="notice-footer" @click="openNoticeCenter">
|
|
<span>查看全部</span>
|
|
<el-icon><ArrowRight /></el-icon>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #reference>
|
|
<div class="left-action-item">
|
|
<el-badge :value="noticeStore.unreadCount" :hidden="noticeStore.unreadCount === 0" :max="99">
|
|
<el-icon :size="18"><Bell /></el-icon>
|
|
</el-badge>
|
|
</div>
|
|
</template>
|
|
</el-popover>
|
|
|
|
<!-- 详情抽屉 -->
|
|
<DetailView ref="detailViewRef" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import DetailView from './DetailView.vue'
|
|
import useNoticeStore from '@/store/modules/notice'
|
|
import { Bell, Notification, ArrowRight } from '@element-plus/icons-vue'
|
|
|
|
const noticeStore = useNoticeStore()
|
|
const noticeVisible = ref(false)
|
|
const activeTab = ref('all')
|
|
const detailViewRef = ref(null)
|
|
|
|
// 过滤后的列表
|
|
const filteredList = computed(() => {
|
|
const list = noticeStore.noticeList
|
|
if (activeTab.value === 'notice') {
|
|
return list.filter(n => n.noticeType === '1')
|
|
}
|
|
if (activeTab.value === 'announce') {
|
|
return list.filter(n => n.noticeType === '2')
|
|
}
|
|
return list
|
|
})
|
|
|
|
// 各类型未读数
|
|
const allUnread = computed(() => noticeStore.unreadCount)
|
|
const noticeUnread = computed(() => {
|
|
return noticeStore.noticeList.filter(n => n.noticeType === '1' && !isRead(n)).length
|
|
})
|
|
const announceUnread = computed(() => {
|
|
return noticeStore.noticeList.filter(n => n.noticeType === '2' && !isRead(n)).length
|
|
})
|
|
|
|
function isRead(item) {
|
|
return item.isRead || item.readFlag === '1' || noticeStore.readIds.has(item.noticeId)
|
|
}
|
|
|
|
// 格式化时间
|
|
function formatTime(time) {
|
|
if (!time) return ''
|
|
const d = new Date(time)
|
|
const now = new Date()
|
|
const diff = now - d
|
|
if (diff < 60000) return '刚刚'
|
|
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
|
|
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
|
|
if (diff < 604800000) return Math.floor(diff / 86400000) + '天前'
|
|
const pad = n => String(n).padStart(2, '0')
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
}
|
|
|
|
// 点击单条 → 标记已读 + 打开详情
|
|
function handleRead(item) {
|
|
if (!isRead(item)) {
|
|
noticeStore.markRead(item.noticeId)
|
|
}
|
|
detailViewRef.value?.open(item)
|
|
}
|
|
|
|
// 全部已读
|
|
function handleReadAll() {
|
|
noticeStore.markAllRead()
|
|
}
|
|
|
|
// 查看全部 → 关闭弹窗,跳转公告页面
|
|
function openNoticeCenter() {
|
|
noticeVisible.value = false
|
|
// 可跳转到公告管理页面或打开全屏面板
|
|
}
|
|
|
|
// 定时刷新未读数
|
|
let timer = null
|
|
onMounted(() => {
|
|
noticeStore.fetchNotices()
|
|
timer = setInterval(() => {
|
|
noticeStore.fetchUnreadCount()
|
|
}, 60000) // 每分钟刷新一次
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
clearInterval(timer)
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.left-action-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0 10px;
|
|
height: 50px;
|
|
font-size: 16px;
|
|
color: #606266;
|
|
cursor: pointer;
|
|
transition: background 0.3s;
|
|
|
|
&:hover {
|
|
background-color: #f6f6f6;
|
|
}
|
|
}
|
|
|
|
:deep(.notice-popover) {
|
|
padding: 0 !important;
|
|
}
|
|
|
|
.notice-popover-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.notice-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 14px 16px 10px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
|
|
.notice-header-title {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: #1a1a1a;
|
|
}
|
|
|
|
.notice-header-action {
|
|
font-size: 12px;
|
|
color: #3B82F6;
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
color: #2563EB;
|
|
}
|
|
}
|
|
}
|
|
|
|
.notice-tabs {
|
|
:deep(.el-tabs__header) {
|
|
margin: 0;
|
|
padding: 0 16px;
|
|
}
|
|
|
|
:deep(.el-tabs__nav-wrap::after) {
|
|
display: none;
|
|
}
|
|
|
|
:deep(.el-tabs__item) {
|
|
padding: 0 12px;
|
|
height: 40px;
|
|
line-height: 40px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.tab-badge {
|
|
margin-left: 4px;
|
|
|
|
:deep(.el-badge__content) {
|
|
font-size: 10px;
|
|
}
|
|
}
|
|
}
|
|
|
|
.notice-scroll {
|
|
max-height: 350px;
|
|
}
|
|
|
|
.notice-empty {
|
|
padding: 30px 0;
|
|
}
|
|
|
|
.notice-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
padding: 12px 16px;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
border-bottom: 1px solid #f5f5f5;
|
|
|
|
&:hover {
|
|
background: #f7f8fa;
|
|
}
|
|
|
|
&:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
&.is-read {
|
|
opacity: 0.55;
|
|
|
|
.notice-item-title span {
|
|
color: #999;
|
|
}
|
|
}
|
|
|
|
.notice-item-icon {
|
|
flex-shrink: 0;
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-top: 2px;
|
|
|
|
.icon-notice {
|
|
background: #E0F2FE;
|
|
color: #0284C7;
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.icon-announce {
|
|
background: #ECFDF5;
|
|
color: #059669;
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
font-size: 16px;
|
|
}
|
|
}
|
|
|
|
.notice-item-body {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.notice-item-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-bottom: 4px;
|
|
|
|
span {
|
|
flex: 1;
|
|
font-size: 13px;
|
|
color: #333;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.unread-tag {
|
|
flex-shrink: 0;
|
|
}
|
|
}
|
|
|
|
.notice-item-time {
|
|
font-size: 11px;
|
|
color: #aaa;
|
|
}
|
|
}
|
|
|
|
.notice-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 4px;
|
|
padding: 10px;
|
|
border-top: 1px solid #f0f0f0;
|
|
font-size: 13px;
|
|
color: #3B82F6;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
|
|
&:hover {
|
|
background: #f7f8fa;
|
|
}
|
|
}
|
|
</style>
|