feat(layout): 优化头部通知组件并实现混合菜单布局

- 重构 HeaderNotice 组件样式,移除外层容器类名并调整图标尺寸
- 将消息通知组件从左侧移动到右侧菜单区域
- 添加 TopBar 组件支持混合菜单和顶部菜单模式
- 实现动态侧边栏显示逻辑,根据导航类型控制侧边栏显示
- 在 Settings 组件中完善菜单导航设置的逻辑判断
- 优化通知轮询机制,添加定时检查新通知功能
- 实现浏览器通知提醒功能,新消息时显示 toast 提示
- 调整权限管理中的路由处理逻辑,确保菜单正常加载
This commit is contained in:
2026-06-04 14:52:05 +08:00
parent 56ec755cf3
commit 4bd20ca0f0
7 changed files with 112 additions and 47 deletions

View File

@@ -1,5 +1,5 @@
<template>
<div>
<div class="header-notice-trigger">
<el-popover
placement="bottom-end"
:width="380"
@@ -85,11 +85,9 @@
</div>
<template #reference>
<div class="notice-trigger-icon">
<el-badge :value="noticeStore.unreadCount" :hidden="noticeStore.unreadCount === 0" :max="99">
<el-icon :size="18"><Bell /></el-icon>
</el-badge>
</div>
<el-badge :value="noticeStore.unreadCount" :hidden="noticeStore.unreadCount === 0" :max="99">
<el-icon><Bell /></el-icon>
</el-badge>
</template>
</el-popover>
</div>
@@ -264,22 +262,21 @@ onMounted(() => {
:deep(p) { margin: 8px 0; }
:deep(a) { color: #3B82F6; }
}
/* 触发器图标 */
.notice-trigger-icon {
.header-notice-trigger {
display: flex;
align-items: center;
justify-content: center;
height: 50px;
padding: 0 10px;
width: 40px;
height: 40px;
border-radius: 6px;
cursor: pointer;
color: #606266;
transition: background 0.3s;
color: #606266;
font-size: 18px;
&:hover {
background-color: #f6f6f6;
color: #3B82F6;
}
.el-icon {
font-size: 18px;
}
}
</style>

View File

@@ -19,8 +19,6 @@
class="left-action-item"
/>
</template>
<!-- 消息通知 -->
<HeaderNotice />
<!-- 帮助中心按钮 -->
<el-tooltip
content="帮助中心"
@@ -48,6 +46,7 @@
</div>
</div>
<div class="right-menu">
<HeaderNotice />
<div class="avatar-container">
<div class="avatar-wrapper">
<el-dropdown

View File

@@ -169,18 +169,22 @@ function handleNavType(val) {
/** 菜单导航设置 */
watch(() => navType, val => {
if (val.value == 1) {
// 纯左侧菜单
appStore.sidebar.opened = true
appStore.toggleSideBarHide(false)
permissionStore.setSidebarRouters(permissionStore.defaultRoutes)
}
if (val.value == 2) {
// 混合菜单:顶部显示一级菜单,左侧显示子菜单
appStore.sidebar.opened = true
appStore.toggleSideBarHide(false)
permissionStore.setSidebarRouters(permissionStore.defaultRoutes)
}
if (val.value == 3) {
// 纯顶部菜单:隐藏侧边栏
appStore.sidebar.opened = false
appStore.toggleSideBarHide(true)
}
if ([1, 3].includes(val.value)) {
permissionStore.setSidebarRouters(permissionStore.defaultRoutes)
permissionStore.setSidebarRouters(permissionStore.defaultRoutes)
}
}, { immediate: true, deep: true }
)

View File

@@ -2,3 +2,4 @@ export { default as AppMain } from './AppMain'
export { default as Navbar } from './Navbar'
export { default as Settings } from './Settings'
export { default as TagsView } from './TagsView/index.vue'
export { default as TopBar } from './TopBar'

View File

@@ -7,11 +7,13 @@
@click="handleClickOutside"
/>
<!-- 左侧侧边栏 -->
<sidebar v-if="!sidebar.hide" />
<sidebar v-if="topNav !== 3 && !sidebar.hide" />
<!-- 右侧主容器 -->
<div class="main-wrapper">
<!-- 顶部导航栏 -->
<navbar @set-layout="setLayout" />
<!-- 顶部菜单栏混合菜单/顶部菜单模式 -->
<TopBar v-if="topNav === 2 || topNav === 3" />
<!-- 内容区 -->
<div
:class="{ 'hasTagsView': needTagsView, 'sidebar-hidden': sidebar.hide }"
@@ -40,7 +42,7 @@
<script setup>
import {useWindowSize} from '@vueuse/core';
import Sidebar from './components/Sidebar/index.vue';
import {AppMain, Settings, TagsView, Navbar} from './components';
import {AppMain, Settings, TagsView, Navbar, TopBar} from './components';
import NoticePopup from '@/components/NoticePopup/index.vue';
import Copyright from './components/Copyright/index.vue';
@@ -54,6 +56,7 @@ const sidebar = computed(() => useAppStore().sidebar);
const device = computed(() => useAppStore().device);
const needTagsView = computed(() => settingsStore.tagsView);
const fixedHeader = computed(() => settingsStore.fixedHeader);
const topNav = computed(() => settingsStore.topNav);
const { width } = useWindowSize();
const WIDTH = 992; // refer to Bootstrap's responsive design

View File

@@ -9,6 +9,7 @@ import useUserStore from '@/store/modules/user'
import useLockStore from '@/store/modules/lock'
import useSettingsStore from '@/store/modules/settings'
import usePermissionStore from '@/store/modules/permission'
import useNoticeStore from '@/store/modules/notice'
// 全局变量,用于控制公告弹窗只显示一次
let hasShownNoticePopup = false
@@ -54,6 +55,7 @@ router.beforeEach(async (to, from) => {
}
}
})
useNoticeStore().startPolling()
return { ...to, replace: true }
} catch (err) {
console.error('路由加载失败:', err)

View File

@@ -1,11 +1,15 @@
import { getUnreadCount, getUserNotices, markAsRead, markAllAsRead, listNoticeTop } from '@/api/system/notice'
import { ElNotification } from 'element-plus'
let pollingTimer = null
const useNoticeStore = defineStore('notice', {
state: () => ({
unreadCount: 0,
noticeList: [],
readIds: new Set(),
loaded: false
loaded: false,
lastUnreadCount: -1
}),
getters: {
@@ -21,6 +25,82 @@ const useNoticeStore = defineStore('notice', {
},
actions: {
// 启动轮询(登录后调用)
startPolling() {
this.stopPolling()
// 立即拉一次
this.fetchNotices()
// 每 30 秒轮询未读数
pollingTimer = setInterval(() => {
this.checkNewNotices()
}, 30000)
},
// 停止轮询(退出登录时调用)
stopPolling() {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
},
// 检查是否有新通知
async checkNewNotices() {
try {
const res = await getUnreadCount()
const newCount = res.data || 0
// 首次记录
if (this.lastUnreadCount === -1) {
this.lastUnreadCount = newCount
this.unreadCount = newCount
return
}
// 未读数增加 → 有新通知
if (newCount > this.lastUnreadCount) {
this.unreadCount = newCount
this.lastUnreadCount = newCount
// 刷新列表
await this.fetchNotices(true)
// 弹出浏览器通知
const diff = newCount - this.lastUnreadCount + (newCount - this.lastUnreadCount)
this.showNewNoticeToast()
} else {
this.unreadCount = newCount
this.lastUnreadCount = newCount
}
} catch (e) {
// ignore
}
},
// 新消息提示
showNewNoticeToast() {
try {
ElNotification({
title: '新消息',
message: '您有新的通知/公告,请查看消息中心',
type: 'info',
duration: 4000,
position: 'top-right'
})
} catch (e) {
// ignore
}
},
// 获取通知列表
async fetchNotices(silent = false) {
try {
const res = await getUserNotices()
this.noticeList = res.data || []
this.loaded = true
this.unreadCount = this.noticeList.filter(n => !n.readFlag || n.readFlag === '0').length
this.lastUnreadCount = this.unreadCount
} catch (e) {
if (!silent) console.error('获取通知列表失败:', e)
}
},
// 获取未读数量
async fetchUnreadCount() {
try {
@@ -31,42 +111,18 @@ const useNoticeStore = defineStore('notice', {
}
},
// 获取通知列表
async fetchNotices() {
try {
const res = await getUserNotices()
this.noticeList = res.data || []
this.loaded = true
// 计算未读数
this.unreadCount = this.noticeList.filter(n => !n.readFlag || n.readFlag === '0').length
} catch (e) {
// ignore
}
},
// 获取顶部通知列表
async fetchTopNotices(query) {
try {
const res = await listNoticeTop(query)
this.noticeList = res.data || []
this.loaded = true
} catch (e) {
// ignore
}
},
// 标记单条已读
async markRead(noticeId) {
try {
await markAsRead(noticeId)
this.readIds.add(noticeId)
// 更新列表中的已读状态
const item = this.noticeList.find(n => n.noticeId === noticeId)
if (item) {
item.isRead = true
item.readFlag = '1'
}
this.unreadCount = Math.max(0, this.unreadCount - 1)
this.lastUnreadCount = this.unreadCount
} catch (e) {
// ignore
}
@@ -86,6 +142,7 @@ const useNoticeStore = defineStore('notice', {
n.readFlag = '1'
})
this.unreadCount = 0
this.lastUnreadCount = 0
}
} catch (e) {
// ignore
@@ -94,10 +151,12 @@ const useNoticeStore = defineStore('notice', {
// 重置状态
reset() {
this.stopPolling()
this.unreadCount = 0
this.noticeList = []
this.readIds = new Set()
this.loaded = false
this.lastUnreadCount = -1
}
}
})