- BizNotifyService 门面: 5 个语义方法 (expertAuditResult / planAuditResult / meetingInvitation / agreementAwaitingSign / projectAssignedToExecutor) - #3 项目分配执行方: 跨调用对比旧 (sessions, amount) 差集去重 - #5 会议邀请: BizMeetingController.add 全量通知, edit 走 userIds 差集去重 - #6 协议待签: updateLaborProtocol 仅在旧 URL 空 → 新 URL 非空时推 - 前端: AdminLayout 全局 bell 角标 + 全局事件总线, doctor/Home 实时刷新, Messages 全部已读, Login.vue 角色统一跳首页, Toast 弹窗上移到 layout - dict 权限分层 (读开放 / 写 admin-only): BizDictController + SysDictDataController 避免 dropdown 403, 但写操作仍需 admin 防止误删
282 lines
12 KiB
Vue
282 lines
12 KiB
Vue
<template>
|
|
<el-container class="admin-layout">
|
|
<el-aside
|
|
v-if="!route.meta?.hideMenu"
|
|
width="220px"
|
|
class="aside"
|
|
>
|
|
<div class="aside-logo">
|
|
<div class="logo-text">{{ roleLabel }}</div>
|
|
<div class="logo-sub">BAHIM 工作台</div>
|
|
</div>
|
|
<el-menu :default-active="route.path" router class="aside-menu">
|
|
<template v-for="m in menu" :key="m.path">
|
|
<!-- 二级菜单: 含 children 的用 el-sub-menu -->
|
|
<el-sub-menu v-if="m.children" :index="m.path">
|
|
<template #title>
|
|
<el-icon><component :is="m.icon" /></el-icon>
|
|
<span>{{ m.title }}</span>
|
|
</template>
|
|
<el-menu-item v-for="c in m.children" :key="c.path" :index="c.path">
|
|
<el-icon><component :is="c.icon" /></el-icon>
|
|
<span>{{ c.title }}</span>
|
|
</el-menu-item>
|
|
</el-sub-menu>
|
|
<!-- 一级菜单 -->
|
|
<el-menu-item v-else :index="m.path">
|
|
<el-icon><component :is="m.icon" /></el-icon>
|
|
<span>{{ m.title }}</span>
|
|
</el-menu-item>
|
|
</template>
|
|
</el-menu>
|
|
</el-aside>
|
|
<el-container>
|
|
<el-header v-if="!route.meta?.hideMenu" class="topbar">
|
|
<div class="topbar-left">
|
|
<span class="page-title">{{ route.meta?.title || '工作台' }}</span>
|
|
</div>
|
|
<div class="topbar-right">
|
|
<el-button text @click="$router.push('/')">门户首页</el-button>
|
|
<!-- 消息入口 (admin/manager/doctor/sponsor/executor 各自路由, 通过 meta.noticePath 配置) -->
|
|
<el-badge v-if="unreadCount > 0" :value="unreadCount" :max="99" class="notice-badge">
|
|
<el-button text @click="goNotice">
|
|
<el-icon><Bell /></el-icon>
|
|
</el-button>
|
|
</el-badge>
|
|
<el-button v-else text @click="goNotice">
|
|
<el-icon><Bell /></el-icon>
|
|
</el-button>
|
|
<el-dropdown trigger="click" @command="onCommand">
|
|
<span class="user-name">{{ store.user?.userName || store.user?.displayName }}<el-icon><CaretBottom /></el-icon></span>
|
|
<template #dropdown>
|
|
<el-dropdown-menu>
|
|
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
|
|
</el-dropdown-menu>
|
|
</template>
|
|
</el-dropdown>
|
|
</div>
|
|
</el-header>
|
|
<el-main class="main" :class="{ 'main-full': route.meta?.hideMenu }">
|
|
<router-view />
|
|
</el-main>
|
|
</el-container>
|
|
</el-container>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useUserStore } from '@/store/user'
|
|
import { logout as logoutApi } from '@/api/auth'
|
|
import { sseStart, sseStop, onGlobal } from '@/utils/sseClient'
|
|
import { listMyMessages } from '@/api/public'
|
|
import { getMyExpertProfile } from '@/api/business/expert'
|
|
import { ElNotification } from 'element-plus'
|
|
import { House, Document, Calendar, User, List, OfficeBuilding, Setting, Bell, EditPen, DataAnalysis, Tickets, CaretBottom, Folder, Medal, UserFilled, Connection, Box, Star, Grid, Files, Compass, Collection } from '@element-plus/icons-vue'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const store = useUserStore()
|
|
const role = computed(() => route.meta?.role || store.role)
|
|
|
|
// ===== 全局未读数 (navbar bell 角标) =====
|
|
// AdminLayout 是唯一永驻组件, 由它统一维护 unreadCount,
|
|
// 任何路由切换都不会丢订阅, 永远正确
|
|
const unreadCount = ref(0)
|
|
|
|
/** 拉一次 /my 拿初始 unread, 刷新页面后角标也对 */
|
|
async function loadInitialUnread() {
|
|
try {
|
|
const r = await listMyMessages({ limit: 1 })
|
|
unreadCount.value = r.unread || 0
|
|
} catch (e) { unreadCount.value = 0 }
|
|
}
|
|
|
|
/** 各角色点 bell 跳到自己的消息页 */
|
|
const NOTICE_PATH = {
|
|
admin: '/admin/workbench', // admin 暂未建独立消息页, 落工作台
|
|
manager: '/manager/workbench', // manager 同上 (Phase 7 补 /manager/messages)
|
|
doctor: '/doctor/messages',
|
|
executor: '/executor/overview', // executor 同 admin/manager 暂用工作台
|
|
sponsor: '/sponsor/home' // sponsor 同上
|
|
}
|
|
function goNotice() {
|
|
const p = NOTICE_PATH[role.value] || '/'
|
|
router.push(p)
|
|
}
|
|
|
|
/** doctor 角色: 拉 biz_expert.auditStatus 同步到 store, 控制侧栏 menu + Home pannel */
|
|
async function loadExpertAuditStatus() {
|
|
if (store.role !== 'doctor') return
|
|
try {
|
|
const { data } = await getMyExpertProfile()
|
|
store.setExpertAuditApproved(data && data.auditStatus === '2')
|
|
} catch (e) {
|
|
// 404/未注册等都视为未通过
|
|
store.setExpertAuditApproved(false)
|
|
}
|
|
}
|
|
|
|
// 登录后启动 SSE 订阅, 组件卸载时关闭 (单例连接, 不重复开)
|
|
let unsubscribeNewMessage = null
|
|
let stopWatchRoute = null
|
|
onMounted(() => {
|
|
if (!store.isLogin) return
|
|
sseStart()
|
|
loadInitialUnread()
|
|
loadExpertAuditStatus()
|
|
// SSE 推送触发 navbar 角标实时更新 + 全局 ElNotification 弹窗 + 医生审核状态刷新
|
|
// (审核通过事件 = user 立刻看到完整菜单/首页; 业务事件 = 弹右上角通知)
|
|
// 弹/不弹由后端 silent flag 决定: false=业务事件触发 (insert) → 弹; true=用户主动操作 (markRead/markAllRead) → 静默
|
|
unsubscribeNewMessage = onGlobal(({ unread, silent }) => {
|
|
unreadCount.value = unread
|
|
if (!silent) {
|
|
ElNotification({
|
|
title: '您有新的通知',
|
|
message: unread > 1 ? `共 ${unread} 条未读` : '点击右上角铃铛查看详情',
|
|
type: 'success',
|
|
duration: 5000,
|
|
position: 'top-right'
|
|
})
|
|
}
|
|
if (store.role === 'doctor') loadExpertAuditStatus()
|
|
})
|
|
// 路由切换时重拉一次 (用户从消息页点列表已读后回首页, 角标要同步减; 医生进入新页面也刷新 audit 状态)
|
|
// immediate:false, 启动时不重复 (onMounted 已拉过)
|
|
stopWatchRoute = watch(() => route.path, () => {
|
|
loadInitialUnread()
|
|
loadExpertAuditStatus()
|
|
})
|
|
})
|
|
onBeforeUnmount(() => {
|
|
sseStop()
|
|
if (unsubscribeNewMessage) unsubscribeNewMessage()
|
|
if (stopWatchRoute) stopWatchRoute()
|
|
})
|
|
|
|
const ROLE_LABEL = { manager: '合规人员', admin: '后台管理员', doctor: '评审专家', executor: '执行人', sponsor: '支持方' }
|
|
const roleLabel = computed(() => ROLE_LABEL[role.value] || '工作台')
|
|
|
|
const MENU = {
|
|
admin: [
|
|
{ path: '/admin/workbench', title: '工作台', icon: House },
|
|
{ path: '/admin/users', title: '用户管理', icon: User },
|
|
{ path: '/admin/roles', title: '角色管理', icon: Setting },
|
|
{ path: '/admin/projects', title: '项目管理', icon: Document },
|
|
{ path: '/admin/library', title: '资料库管理', icon: Box, children: [
|
|
{ path: '/admin/experts', title: '专家管理', icon: UserFilled },
|
|
{ path: '/admin/sponsor-orgs', title: '支持单位管理', icon: Star },
|
|
{ path: '/admin/executor-orgs', title: '服务机构管理', icon: OfficeBuilding },
|
|
{ path: '/admin/department', title: '科室管理', icon: Grid },
|
|
{ path: '/admin/title', title: '职称管理', icon: Medal },
|
|
{ path: '/admin/project-category', title: '项目类别管理', icon: Collection }
|
|
]},
|
|
{ path: '/admin/manage', title: '网站管理', icon: Setting, children: [
|
|
{ path: '/admin/article', title: '协议管理', icon: Files },
|
|
{ path: '/admin/special-plan', title: '专项计划管理', icon: Compass },
|
|
{ path: '/admin/labor-protocol', title: '劳务协议配置', icon: Document }
|
|
]},
|
|
{ path: '/admin/account', title: '账号信息', icon: User }
|
|
],
|
|
manager: [
|
|
{ path: '/manager/workbench', title: '工作台', icon: House },
|
|
{ path: '/manager/plans', title: '项目策划方案', icon: EditPen },
|
|
{ path: '/manager/projects', title: '项目管理', icon: Document },
|
|
{ path: '/manager/meetings', title: '会议管理', icon: Calendar },
|
|
{ path: '/manager/experts', title: '专家审核', icon: User },
|
|
{ path: '/manager/sponsor-orgs', title: '支持单位管理', icon: Connection },
|
|
{ path: '/manager/executor-orgs', title: '服务机构管理', icon: OfficeBuilding },
|
|
{ path: '/manager/support-intent', title: '支持意向', icon: Tickets },
|
|
{ path: '/manager/exec-intent', title: '执行意向', icon: Tickets },
|
|
{ path: '/manager/accounts', title: '账号管理', icon: Setting }
|
|
],
|
|
doctor: [
|
|
{ path: '/doctor/home', title: '首页', icon: House },
|
|
// 以下 3 项仅审核通过 (audit_status='2') 才显示, 由 menu.filter 用 requireAuditApproved 过滤
|
|
{ path: '/doctor/meetings', title: '我参与的会议', icon: Calendar, requireAuditApproved: true },
|
|
{ path: '/doctor/projects', title: '我报名的项目', icon: Document, requireAuditApproved: true },
|
|
{ path: '/doctor/messages', title: '消息通知', icon: Bell },
|
|
{ path: '/doctor/submissions', title: '我的项目设计投稿', icon: EditPen, requireAuditApproved: true },
|
|
{ path: '/doctor/account', title: '账号信息', icon: User }
|
|
],
|
|
executor: [
|
|
{ path: '/executor/overview', title: '首页', icon: House },
|
|
{ path: '/executor/meetings', title: '会议执行', icon: Calendar },
|
|
{ path: '/executor/projects', title: '项目列表', icon: Document },
|
|
{ path: '/executor/people', title: '人员管理', icon: User, requireMain: true },
|
|
{ path: '/executor/labor', title: '劳务凭证', icon: EditPen },
|
|
{ path: '/executor/account', title: '账号信息', icon: Setting }
|
|
],
|
|
sponsor: [
|
|
{ path: '/sponsor/home', title: '首页', icon: House },
|
|
{ path: '/sponsor/my-projects', title: '我的项目', icon: Document },
|
|
{ path: '/sponsor/meetings', title: '会议列表', icon: Calendar },
|
|
{ path: '/sponsor/people', title: '人员管理', icon: User, requireMain: true },
|
|
{ path: '/sponsor/account', title: '账号信息', icon: Setting }
|
|
]
|
|
}
|
|
const menu = computed(() => (MENU[role.value] || []).filter(item =>
|
|
(!item.requireMain || store.isMain) &&
|
|
(!item.requireAuditApproved || store.expertAuditApproved)
|
|
))
|
|
|
|
const logout = async () => {
|
|
try { await logoutApi() } catch {}
|
|
sseStop()
|
|
store.logout()
|
|
router.replace('/login')
|
|
}
|
|
const onCommand = (cmd) => { if (cmd === 'logout') logout() }
|
|
</script>
|
|
|
|
<style scoped>
|
|
.admin-layout { height: 100vh; }
|
|
.aside { background: #001529; color: #fff; }
|
|
.aside-logo { padding: 16px; text-align: center; border-bottom: 1px solid var(--brand-primary-darker); }
|
|
.logo-text { color: #fff; font-size: 16px; font-weight: 600; }
|
|
.logo-sub { color: #8a99b3; font-size: 12px; }
|
|
.aside-menu { background: #001529; border: none; }
|
|
|
|
/* 一级菜单 + 二级父标题: 统一默认/hover/active 样式 */
|
|
:deep(.el-menu-item),
|
|
:deep(.el-sub-menu__title) {
|
|
color: #c0c4cc;
|
|
}
|
|
:deep(.el-menu-item:hover),
|
|
:deep(.el-sub-menu__title:hover) {
|
|
background-color: #1f3a5f;
|
|
color: #fff;
|
|
}
|
|
/* 子项激活: 蓝底白字 */
|
|
:deep(.el-menu-item.is-active) {
|
|
background: #1890ff;
|
|
color: #fff;
|
|
}
|
|
/* 父标题在子项激活时高亮 (Element Plus 自动给 el-sub-menu 加 is-active) */
|
|
:deep(.el-sub-menu.is-active > .el-sub-menu__title) {
|
|
color: #fff;
|
|
}
|
|
|
|
/* 二级菜单容器: 跟随侧边栏深蓝底, 不要 Element Plus 默认白底 */
|
|
:deep(.el-menu--inline),
|
|
:deep(.el-menu--inline .el-menu-item) {
|
|
background-color: #001529;
|
|
}
|
|
:deep(.el-menu--inline .el-menu-item:hover) {
|
|
background-color: #1f3a5f;
|
|
}
|
|
:deep(.el-menu--inline .el-menu-item.is-active) {
|
|
background: #1890ff;
|
|
}
|
|
.topbar { background: #fff; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid #ebeef5; }
|
|
.topbar-left { display: flex; align-items: center; }
|
|
.page-title { font-size: 16px; font-weight: 600; }
|
|
.topbar-right { display: flex; align-items: center; gap: 12px; }
|
|
.user-name { cursor: pointer; display: inline-flex; align-items: center; gap: 4px; }
|
|
/* navbar bell 角标: 紧凑, 不撑大 topbar */
|
|
.notice-badge :deep(.el-badge__content) { transform: translate(50%, -10%); }
|
|
.main { background: #f5f7fa; padding: 16px; }
|
|
/* 手机页面 (hideMenu): 白底 + 0 padding, 让 form 直接撑满 */
|
|
.main-full { background: #fff; padding: 0; }
|
|
</style>
|