feat(msg): SSE 实时通知体系 (Phase 1-4 + 7 部分)
后端:
- MessageSseService (emitter 注册表, 25s 心跳, pushNewMessage 仅 log 不抛)
- BizMessageSseController (独立 Controller, 避开 /{msgId} 路径抢匹配)
- BizMessageMapper.countUnread SQL 修复 (跟 limit 解耦, SSE 推真值)
- BizMessageServiceImpl.insert/markRead 成功后 pushNewMessage 触发 SSE
- BizNotifyService.expertAuditResult (#1: 审核结果通知专家本人)
- BizExpertServiceImpl.updateByPrimaryKey 比对旧 audit_status, 真变化才发通知
- PlaceholderConfig (Spring 7 严格 placeholder 兜底, ignoreUnresolvable=true)
前端:
- utils/sseClient.js (@microsoft/fetch-event-source 封装, Bearer 鉴权, 单例连接)
- 全局事件总线 onGlobal: SSE new_message 扇出到业务页面 (切路由不掉)
- AdminLayout 全局订阅 + bell 角标 (el-badge 显示 unreadCount)
- userStore.expertAuditApproved: doctor 审核状态实时跟 DB
- AdminLayout doctor menu 加 requireAuditApproved 过滤 (未通过审核隐藏 3 menu)
- doctor/Home: 待参加会议/待签协议 2 pannel 按 audit 状态显示; load() 仅审核通过才拉数据
- doctor/Home + doctor/Messages: 点列表弹详情 dialog + markMessageRead 真已读
- AdminLayout SSE 触发时: 刷新 unread + 重新拉 audit 状态 (审核通过瞬间可见)
业务规则:
- 专家注册不群发 manager (manager 群体可能 100+, 每条注册发 N 条浪费)
- 通过审核无需重新登录 (SSE 链路实时联动)
This commit is contained in:
@@ -37,6 +37,15 @@
|
||||
</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>
|
||||
@@ -55,10 +64,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
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, on as sseOn } from '@/utils/sseClient'
|
||||
import { listMyMessages } from '@/api/public'
|
||||
import { getMyExpertProfile } from '@/api/business/expert'
|
||||
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()
|
||||
@@ -66,6 +78,70 @@ 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 角标实时更新 + 医生审核状态刷新 (审核通过事件 = user 立刻看到完整菜单/首页)
|
||||
unsubscribeNewMessage = sseOn('new_message', ({ unread }) => {
|
||||
unreadCount.value = unread
|
||||
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] || '工作台')
|
||||
|
||||
@@ -104,10 +180,11 @@ const MENU = {
|
||||
],
|
||||
doctor: [
|
||||
{ path: '/doctor/home', title: '首页', icon: House },
|
||||
{ path: '/doctor/meetings', title: '我参与的会议', icon: Calendar },
|
||||
{ path: '/doctor/projects', title: '我报名的项目', icon: Document },
|
||||
// 以下 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 },
|
||||
{ path: '/doctor/submissions', title: '我的项目设计投稿', icon: EditPen, requireAuditApproved: true },
|
||||
{ path: '/doctor/account', title: '账号信息', icon: User }
|
||||
],
|
||||
executor: [
|
||||
@@ -126,10 +203,14 @@ const MENU = {
|
||||
{ path: '/sponsor/account', title: '账号信息', icon: Setting }
|
||||
]
|
||||
}
|
||||
const menu = computed(() => (MENU[role.value] || []).filter(item => !item.requireMain || store.isMain))
|
||||
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')
|
||||
}
|
||||
@@ -180,6 +261,8 @@ const onCommand = (cmd) => { if (cmd === 'logout') logout() }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user