feat(notice): 重构通知公告功能实现消息中心

- 新增顶部公告/通知列表获取接口 listNoticeTop
- 新增标记单条公告为已读接口 markNoticeRead
- 新增批量标记公告为已读接口 markNoticeReadAll
- 重构 HeaderNotice 组件实现完整的消息中心功能
- 添加标签页分类显示全部、通知、公告三种类型
- 实现消息实时未读数统计和标记已读功能
- 优化消息展示界面增加图标区分通知和公告类型
- 更新 Navbar 组件集成新的消息中心功能
- 调整布局样式适配消息中心组件
- 修复设置面板导航类型配置问题
- 添加 Chrome 风格标签页样式支持
This commit is contained in:
2026-06-04 12:57:04 +08:00
parent 14a81564bf
commit 58669ce9b6
11 changed files with 826 additions and 312 deletions

View File

@@ -97,6 +97,7 @@ export function sign(practitionerId, mac, ip) {
method: 'post',
})
}
// 锁屏解锁(验证登录状态)
export function unlockScreen(password) {
return request({

View File

@@ -108,3 +108,29 @@ export function getReadNoticeIds() {
method: 'get'
})
}
// 获取顶部公告/通知列表最新N条
export function listNoticeTop(query) {
return request({
url: '/system/notice/public/top',
method: 'get',
params: query
})
}
// 标记单条公告/通知为已读
export function markNoticeRead(noticeId) {
return request({
url: '/system/notice/public/read/' + noticeId,
method: 'post'
})
}
// 批量标记公告/通知为已读逗号分隔的ID字符串
export function markNoticeReadAll(noticeIds) {
return request({
url: '/system/notice/public/read/all',
method: 'post',
data: noticeIds
})
}

View File

@@ -1,184 +1,360 @@
<template>
<div>
<el-popover ref="noticePopover" placement="bottom-end" :width="320" trigger="manual" v-model:visible="noticeVisible" popper-class="notice-popover">
<!-- 弹出内容 -->
<div class="notice-header">
<span class="notice-title">通知公告</span>
<span class="notice-mark-all" @click="markAllRead">全部已读</span>
</div>
<div v-if="noticeLoading" class="notice-loading">
<el-icon class="is-loading"><Loading /></el-icon> 加载中...
</div>
<div v-else-if="noticeList.length === 0" class="notice-empty">
<el-icon style="font-size:24px;display:block;margin-bottom:6px;"><Postcard /></el-icon>
暂无公告
</div>
<div v-else>
<div v-for="item in noticeList" :key="item.noticeId" class="notice-item" :class="{ 'is-read': item.isRead }" @click="previewNotice(item)">
<el-tag size="small" :type="item.noticeType === '1' ? 'warning' : 'success'" class="notice-tag">
{{ item.noticeType === '1' ? '通知' : '公告' }}
</el-tag>
<span class="notice-item-title">{{ item.noticeTitle }}</span>
<span class="notice-item-date">{{ item.createTime }}</span>
</div>
</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="right-menu-item hover-effect notice-trigger" @mouseenter="onNoticeEnter" @mouseleave="onNoticeLeave">
<svg-icon icon-class="bell" />
<span v-if="unreadCount > 0" class="notice-badge">{{ unreadCount }}</span>
<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>
<!-- 预览弹窗 -->
<notice-detail-view ref="noticeViewRef" />
<!-- 详情抽屉 -->
<DetailView ref="detailViewRef" />
</div>
</template>
<script setup>
import NoticeDetailView from './DetailView'
import { listNoticeTop, markNoticeRead, markNoticeReadAll } from '@/api/system/notice'
import DetailView from './DetailView.vue'
import useNoticeStore from '@/store/modules/notice'
import { Bell, Notification, ArrowRight } from '@element-plus/icons-vue'
const noticePopover = ref(null)
const noticeList = ref([])
const unreadCount = ref(0)
const noticeLoading = ref(false)
const noticeStore = useNoticeStore()
const noticeVisible = ref(false)
const noticeLeaveTimer = ref(null)
const { proxy } = getCurrentInstance()
const activeTab = ref('all')
const detailViewRef = ref(null)
// 加载顶部公告列表
function loadNoticeTop() {
noticeLoading.value = true
listNoticeTop().then(res => {
noticeList.value = res.data || []
unreadCount.value = res.unreadCount !== undefined ? res.unreadCount : noticeList.value.filter(n => !n.isRead).length
}).finally(() => {
noticeLoading.value = false
})
}
onMounted(() => loadNoticeTop())
// 鼠标移入铃铛区域
function onNoticeEnter() {
clearTimeout(noticeLeaveTimer.value)
noticeVisible.value = true
nextTick(() => {
const popper = noticePopover.value?.popperRef?.contentRef
if (popper && !popper._noticeBound) {
popper._noticeBound = true
popper.addEventListener('mouseenter', () => clearTimeout(noticeLeaveTimer.value))
popper.addEventListener('mouseleave', () => {
noticeLeaveTimer.value = setTimeout(() => { noticeVisible.value = false }, 100)
})
}
})
}
// 鼠标离开铃铛区域
function onNoticeLeave() {
noticeLeaveTimer.value = setTimeout(() => { noticeVisible.value = false }, 150)
}
// 预览公告详情
function previewNotice(item) {
if (!item.isRead) {
markNoticeRead(item.noticeId).catch(() => {})
const idx = noticeList.value.indexOf(item)
if (idx !== -1) noticeList.value[idx] = { ...item, isRead: true }
unreadCount.value = Math.max(0, unreadCount.value - 1)
// 过滤后的列表
const filteredList = computed(() => {
const list = noticeStore.noticeList
if (activeTab.value === 'notice') {
return list.filter(n => n.noticeType === '1')
}
proxy.$refs["noticeViewRef"].open(item.noticeId)
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 markAllRead() {
const ids = noticeList.value.map(n => n.noticeId).join(',')
if (!ids) return
markNoticeReadAll(ids).catch(() => {})
noticeList.value = noticeList.value.map(n => ({ ...n, isRead: true }))
unreadCount.value = 0
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>
.notice-trigger {
position: relative;
transform: translateX(-6px);
.svg-icon { width: 1.2em; height: 1.2em; vertical-align: -0.2em; }
.notice-badge {
position: absolute;
top: 7px;
right: -3px;
background: #f56c6c;
color: #fff;
border-radius: 10px;
font-size: 10px;
height: 16px;
line-height: 16px;
padding: 0 4px;
min-width: 16px;
text-align: center;
white-space: nowrap;
pointer-events: none;
.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;
}
}
.notice-popover { padding: 0 !important; }
.notice-popover .notice-header {
: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: 10px 14px;
background: #f7f9fb;
border-bottom: 1px solid #eee;
font-size: 13px;
font-weight: 600;
color: #333;
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-popover .notice-mark-all {
font-size: 12px;
color: var(--el-color-primary);
font-weight: normal;
.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-popover .notice-mark-all:hover { color: #2b7cc1; }
.notice-popover .notice-loading,
.notice-popover .notice-empty {
padding: 24px;
text-align: center;
color: #bbb;
font-size: 12px;
line-height: 1.8;
}
.notice-popover .notice-item {
.notice-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #f5f5f5;
justify-content: center;
gap: 4px;
padding: 10px;
border-top: 1px solid #f0f0f0;
font-size: 13px;
color: #3B82F6;
cursor: pointer;
transition: background 0.15s;
}
.notice-popover .notice-item:last-child { border-bottom: none; }
.notice-popover .notice-item:hover { background: #f7f9fb; }
.notice-popover .notice-item.is-read .notice-tag,
.notice-popover .notice-item.is-read .notice-item-title,
.notice-popover .notice-item.is-read .notice-item-date { opacity: 0.45; filter: grayscale(1); color: #999; }
.notice-popover .notice-tag { flex-shrink: 0; }
.notice-popover .notice-item-title {
flex: 1;
font-size: 12px;
color: #333;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.notice-popover .notice-item-date {
flex-shrink: 0;
font-size: 11px;
color: #bbb;
transition: background 0.2s;
&:hover {
background: #f7f8fa;
}
}
</style>

View File

@@ -11,7 +11,7 @@
</el-icon>
</div>
</div>
<!-- 搜索åŒå¬åŠéšçŸ¥ -->
<!-- 搜索和公告通知 -->
<div class="left-actions">
<template v-if="appStore.device !== 'mobile'">
<header-search
@@ -19,27 +19,67 @@
class="left-action-item"
/>
</template>
<!-- å¬åŠåŒéšçŸ¥æŒé® -->
<el-tooltip
content="公告/通知"
placement="bottom"
<!-- 消息通知 -->
<el-popover
placement="bottom-end"
:width="360"
trigger="click"
v-model:visible="noticeVisible"
popper-class="notice-popover"
:show-arrow="false"
>
<div
class="left-action-item notice-btn"
@click="openNoticePanel"
>
<el-badge
:value="unreadCount"
:hidden="unreadCount === 0"
class="notice-badge"
>
<el-icon><Bell /></el-icon>
</el-badge>
<div class="notice-popover-content">
<div class="notice-header">
<span class="notice-header-title">消息中心</span>
<span class="notice-header-action" @click="handleNoticeReadAll">全部已读</span>
</div>
<el-tabs v-model="noticeTab" class="notice-tabs">
<el-tab-pane name="all">
<template #label>全部<el-badge v-if="noticeStore.unreadCount > 0" :value="noticeStore.unreadCount" :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">
<div v-if="noticeFilteredList.length === 0" class="notice-empty">
<el-empty description="暂无消息" :image-size="60" />
</div>
<div
v-for="item in noticeFilteredList"
:key="item.noticeId"
class="notice-item"
:class="{ 'is-read': noticeIsRead(item) }"
@click="handleNoticeRead(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="!noticeIsRead(item)" type="danger" size="small" effect="dark" class="unread-tag">未读</el-tag>
</div>
<div class="notice-item-time">{{ noticeFormatTime(item.createTime) }}</div>
</div>
</div>
</el-scrollbar>
</div>
</el-tooltip>
<!-- 帮助中心æŒé® -->
<template #reference>
<div class="left-action-item">
<el-badge :value="noticeStore.unreadCount" :hidden="noticeStore.unreadCount === 0" :max="99">
<el-icon><Notification /></el-icon>
</el-badge>
</div>
</template>
</el-popover>
<!-- 帮助中心按钮 -->
<el-tooltip
content="帮助中心"
content="帮助中心"
placement="bottom"
>
<div
@@ -93,19 +133,19 @@
<template #dropdown>
<el-dropdown-menu>
<router-link to="/user/profile">
<el-dropdown-item>个人中心</el-dropdown-item>
<el-dropdown-item>个人中心</el-dropdown-item>
</router-link>
<el-dropdown-item
divided
command="lockScreen"
>
<span>éå®šå±å¹</span>
<span>锁定屏幕</span>
</el-dropdown-item>
<el-dropdown-item
divided
command="logout"
>
<span>éåºç»å½</span>
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -138,7 +178,7 @@
</div>
<el-dialog
v-model="showDialog"
title="切换科室"
title="切换科室"
width="400px"
append-to-body
destroy-on-close
@@ -161,147 +201,152 @@
type="primary"
@click="submit"
>
确定
确定
</el-button>
<el-button @click="showDialog = false">
åæˆ
取消
</el-button>
</div>
</template>
</el-dialog>
<!-- å¬åŠ/éšçŸ¥é¢æ¿ -->
<NoticePanel
ref="noticePanelRef"
@update-unread-count="updateUnreadCount"
/>
<DetailView ref="detailViewRef" />
</div>
</template>
<script setup>
import {onMounted, ref, computed} from 'vue';
import {ElMessageBox} from 'element-plus';
import {Fold, Expand, Bell} from '@element-plus/icons-vue';
import HeaderSearch from '@/components/HeaderSearch';
import NoticePanel from '@/components/NoticePanel';
import useAppStore from '@/store/modules/app';
import useUserStore from '@/store/modules/user'
import useLockStore from '@/store/modules/lock';
import {getOrg, switchOrg} from '@/api/login';
import {getUnreadCount} from '@/api/system/notice';
import {useRouter} from 'vue-router';
import {Fold, Expand} from '@element-plus/icons-vue';
import {Help, Setting} from "@element-plus/icons-vue";
import HeaderSearch from '@/components/HeaderSearch/index.vue';
import DetailView from '@/layout/components/HeaderNotice/DetailView.vue'
import useNoticeStore from '@/store/modules/notice'
import { Notification } from '@element-plus/icons-vue'
import useAppStore from '@/store/modules/app';
import useUserStore from '@/store/modules/user';
import useLockStore from '@/store/modules/lock';
import usePermissionStore from '@/store/modules/permission';
import {getOrg, switchOrg} from '@/api/login';
const appStore = useAppStore();
const userStore = useUserStore()
const userStore = useUserStore();
const lockStore = useLockStore();
const permissionStore = usePermissionStore();
const route = useRoute();
const router = useRouter();
const orgOptions = ref([]);
const showDialog = ref(false);
const orgId = ref('');
const noticePanelRef = ref(null);
const unreadCount = ref(0);
const {proxy} = getCurrentInstance();
const sidebar = computed(() => appStore.sidebar);
// 加载未读数量
function loadUnreadCount() {
getUnreadCount().then(res => {
unreadCount.value = res.data || 0;
}).catch(() => {
unreadCount.value = 0;
});
}
const showDialog = ref(false);
const orgId = ref('');
const orgOptions = ref([]);
// 更新未读数量
function updateUnreadCount() {
loadUnreadCount();
}
// 切换侧边栏
// 切换侧边栏
function toggleSideBar() {
appStore.toggleSideBar();
}
function loadOrgList() {
getOrg().then((res) => {
orgOptions.value = res.data;
});
}
onMounted(() => {
loadOrgList();
loadUnreadCount();
});
function handleOrgSwitch(selectedOrgId) {
if (selectedOrgId === userStore.orgId) {
return;
}
const selectedOrg = orgOptions.value.find((item) => item.orgId === selectedOrgId);
const orgName = selectedOrg ? selectedOrg.orgName : '该科室';
ElMessageBox.confirm(`确定要切换到科室"${orgName}"吗?`, '切换科室', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
orgId.value = selectedOrgId;
switchOrg(selectedOrgId).then((res) => {
if (res.code === 200) {
userStore.logOut().then(() => {
location.href = '/index';
});
}
});
});
}
function handleCommand(command) {
switch (command) {
case 'setLayout':
setLayout();
break;
case 'lockScreen':
lockScreen()
break
lockStore.lockScreen(route.path);
router.push('/lock');
break;
case 'logout':
logout();
break;
default:
break;
}
}
function lockScreen() {
const currentPath = router.currentRoute.value.path
lockStore.lockScreen(currentPath)
router.push('/lock')
async function loadOrgList() {
try {
const res = await getOrg();
orgOptions.value = res.data || [];
} catch (e) {
// ignore
}
}
function logout() {
ElMessageBox.confirm('确定注销并退出系统吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
userStore.logOut().then(() => {
location.href = '/index';
});
})
.catch(() => {});
async function handleOrgSwitch(val) {
const selectedOrg = orgOptions.value.find(item => item.orgId === val);
const orgName = selectedOrg ? selectedOrg.orgName : '该科室';
ElMessageBox.confirm(`确定要切换到科室"${orgName}"吗?`, '切换科室', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await switchOrg(val);
permissionStore.setRoutes([]);
router.replace({path: '/redirect' + route.path});
} catch (e) {
// ignore
}
}).catch(() => {});
}
function submit() {
switchOrg(orgId.value).then((res) => {
if (res.code === 200) {
userStore.logOut().then(() => {
location.href = '/index';
});
}
});
handleOrgSwitch(orgId.value);
}
function logout() {
ElMessageBox.confirm('确定注销并退出系统吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
userStore.logOut().then(() => {
router.push('/login');
});
}).catch(() => {});
}
function goToHelpCenter() {
router.push('/help');
}
const noticeStore = useNoticeStore()
const noticeVisible = ref(false)
const noticeTab = ref('all')
const detailViewRef = ref(null)
const noticeFilteredList = computed(() => {
const list = noticeStore.noticeList
if (noticeTab.value === 'notice') return list.filter(n => n.noticeType === '1')
if (noticeTab.value === 'announce') return list.filter(n => n.noticeType === '2')
return list
})
const noticeUnread = computed(() => noticeStore.noticeList.filter(n => n.noticeType === '1' && !noticeIsRead(n)).length)
const announceUnread = computed(() => noticeStore.noticeList.filter(n => n.noticeType === '2' && !noticeIsRead(n)).length)
function noticeIsRead(item) {
return item.isRead || item.readFlag === '1' || noticeStore.readIds.has(item.noticeId)
}
function noticeFormatTime(time) {
if (!time) return ''
const d = new Date(time), now = new Date(), 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 handleNoticeRead(item) {
if (!noticeIsRead(item)) noticeStore.markRead(item.noticeId)
detailViewRef.value?.open(item)
}
function handleNoticeReadAll() {
noticeStore.markAllRead()
}
const emits = defineEmits(['setLayout']);
@@ -309,53 +354,43 @@ function setLayout() {
emits('setLayout');
}
// 打开公告/通知面板
function openNoticePanel() {
if (noticePanelRef.value) {
noticePanelRef.value.open();
}
}
function goToHelpCenter() {
window.open(window.location.origin + '/help-center/vuepress-theme-vdoing-doc/docs/.vuepress/dist/pages/520e67/index.html', '_blank');
}
onMounted(() => {
loadOrgList();
noticeStore.fetchNotices();
});
</script>
<style lang='scss' scoped>
<style lang="scss" scoped>
.navbar {
height: 50px;
width: 100%;
overflow: hidden;
position: relative;
background-color: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15px;
height: 50px;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
padding: 0;
position: relative;
.left-menu {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 8px;
flex: 1;
min-width: 0;
.hamburger-container {
display: flex;
align-items: center;
.hamburger {
cursor: pointer;
padding: 0 12px;
padding: 0 15px;
height: 50px;
display: flex;
align-items: center;
cursor: pointer;
transition: background 0.3s;
color: #5a5e66;
&:hover {
background-color: #f6f6f6;
color: var(--el-color-primary);
}
}
}
@@ -612,3 +647,118 @@ function goToHelpCenter() {
}
}
</style>
<style lang="scss">
/* 通知弹窗样式 */
.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 {
.el-tabs__header {
margin: 0;
padding: 0 16px;
}
.el-tabs__nav-wrap::after {
display: none;
}
.el-tabs__item {
padding: 0 12px;
height: 40px;
line-height: 40px;
font-size: 13px;
}
.tab-badge {
margin-left: 4px;
.el-badge__content { font-size: 10px; }
}
}
.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;
}
}
</style>

View File

@@ -134,7 +134,7 @@ const appStore = useAppStore()
const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore()
const showSettings = ref(false)
const navType = ref(settingsStore.navType)
const navType = ref(settingsStore.topNav)
const theme = ref(settingsStore.theme)
const sideTheme = ref(settingsStore.sideTheme)
const tagsViewPersist = ref(settingsStore.tagsViewPersist)
@@ -162,7 +162,7 @@ function handleTheme(val) {
}
function handleNavType(val) {
settingsStore.navType = val
settingsStore.topNav = val
navType.value = val
}
@@ -191,7 +191,7 @@ function saveSetting() {
proxy.$cache.local.remove('tags-view-visited')
}
let layoutSetting = {
"navType": storeSettings.value.navType,
"topNav": storeSettings.value.topNav,
"tagsView": storeSettings.value.tagsView,
"tagsIcon": storeSettings.value.tagsIcon,
"tagsViewStyle": storeSettings.value.tagsViewStyle,

View File

@@ -2,6 +2,7 @@
<div
id="tags-view-container"
class="tags-view-container"
:class="{ 'chrome-style': tagsViewStyle === 'chrome' }"
>
<scroll-pane
ref="scrollPaneRef"
@@ -88,6 +89,7 @@ const router = useRouter();
const visitedViews = computed(() => useTagsViewStore().visitedViews);
const routes = computed(() => usePermissionStore().routes);
const theme = computed(() => useSettingsStore().theme);
const tagsViewStyle = computed(() => useSettingsStore().tagsViewStyle);
watch(route, () => {
addTags();
@@ -407,6 +409,27 @@ function handleScroll() {
}
}
}
&.chrome-style {
background: #f1f3f4;
.tags-view-wrapper {
.tags-view-item {
border: none;
border-radius: 8px 8px 0 0;
background: #dadce0;
color: #5f6368;
margin-left: -1px;
padding: 0 16px;
&.active {
background: #fff;
color: #202124;
border-color: transparent;
&::before {
display: none;
}
}
}
}
}
}
</style>
@@ -435,5 +458,26 @@ function handleScroll() {
}
}
}
&.chrome-style {
background: #f1f3f4;
.tags-view-wrapper {
.tags-view-item {
border: none;
border-radius: 8px 8px 0 0;
background: #dadce0;
color: #5f6368;
margin-left: -1px;
padding: 0 16px;
&.active {
background: #fff;
color: #202124;
border-color: transparent;
&::before {
display: none;
}
}
}
}
}
}
</style>

View File

@@ -14,7 +14,7 @@
<navbar @set-layout="setLayout" />
<!-- 内容区 -->
<div
:class="{ 'hasTagsView': needTagsView }"
:class="{ 'hasTagsView': needTagsView, 'sidebar-hidden': sidebar.hide }"
class="content-wrapper"
>
<!-- 标签栏 -->
@@ -138,11 +138,19 @@ defineExpose({
position: fixed;
top: 50px;
right: 0;
left: 0;
left: 200px;
z-index: 9;
width: 100%;
width: calc(100% - 200px);
padding: 0 15px;
background: #fff;
box-sizing: border-box;
}
.sidebar-hidden {
.fixed-header {
left: 0 !important;
width: 100% !important;
}
}
.sidebarHide {

View File

@@ -454,19 +454,9 @@ export const dynamicRoutes = [
}
];
// 合并常量路由和动态路由,确保所有路由都能被访问
const allRoutes = [...constantRoutes, ...dynamicRoutes];
// 添加404路由到所有路由的最后
allRoutes.push({
path: "/:pathMatch(.*)*",
component: () => import('@/views/error/404'),
hidden: true
});
const router = createRouter({
history: createWebHistory(),
routes: allRoutes,
routes: constantRoutes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
@@ -476,4 +466,17 @@ const router = createRouter({
},
});
// 动态路由加载完成后再添加 404 catch-allVue Router 4 要求)
export function addNotFoundRoute() {
if (!router.hasRoute('not-found')) {
router.addRoute({
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/error/404'),
hidden: true
})
}
}
export default router;

View File

@@ -0,0 +1,105 @@
import { getUnreadCount, getUserNotices, markAsRead, markAllAsRead, listNoticeTop } from '@/api/system/notice'
const useNoticeStore = defineStore('notice', {
state: () => ({
unreadCount: 0,
noticeList: [],
readIds: new Set(),
loaded: false
}),
getters: {
unreadList(state) {
return state.noticeList.filter(n => !state.readIds.has(n.noticeId))
},
noticeTypeList(state) {
return state.noticeList.filter(n => n.noticeType === '2')
},
informTypeList(state) {
return state.noticeList.filter(n => n.noticeType === '1')
}
},
actions: {
// 获取未读数量
async fetchUnreadCount() {
try {
const res = await getUnreadCount()
this.unreadCount = res.data || 0
} catch (e) {
// ignore
}
},
// 获取通知列表
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)
} catch (e) {
// ignore
}
},
// 全部标记已读
async markAllRead() {
try {
const unreadIds = this.noticeList
.filter(n => !this.readIds.has(n.noticeId))
.map(n => n.noticeId)
if (unreadIds.length > 0) {
await markAllAsRead(unreadIds)
unreadIds.forEach(id => this.readIds.add(id))
this.noticeList.forEach(n => {
n.isRead = true
n.readFlag = '1'
})
this.unreadCount = 0
}
} catch (e) {
// ignore
}
},
// 重置状态
reset() {
this.unreadCount = 0
this.noticeList = []
this.readIds = new Set()
this.loaded = false
}
}
})
export default useNoticeStore

View File

@@ -1,5 +1,5 @@
import auth from '@/plugins/auth'
import router, {constantRoutes, dynamicRoutes} from '@/router'
import router, {constantRoutes, dynamicRoutes, addNotFoundRoute} from '@/router'
import {getRouters} from '@/api/menu'
import Layout from '@/layout/index'
import ParentView from '@/components/ParentView'
@@ -44,6 +44,7 @@ const usePermissionStore = defineStore(
const defaultRoutes = filterAsyncRouter(defaultData)
const asyncRoutes = filterDynamicRoutes(dynamicRoutes)
asyncRoutes.forEach(route => { router.addRoute(route) })
addNotFoundRoute()
this.setRoutes(rewriteRoutes)
this.setSidebarRouters(constantRoutes.concat(sidebarRoutes))
this.setDefaultRoutes(sidebarRoutes)

View File

@@ -2,7 +2,7 @@ import defaultSettings from '@/settings'
import {useDynamicTitle} from '@/utils/dynamicTitle'
const {
sideTheme, showSettings, navType: topNav, tagsView, tagsViewPersist,
sideTheme, showSettings, topNav, tagsView, tagsViewPersist,
tagsIcon, tagsViewStyle, fixedHeader, sidebarLogo, dynamicTitle,
footerVisible, footerContent
} = defaultSettings