feat(msg): SSE 实时通知体系 (#1-#6) + dict 接口权限分层
- 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 防止误删
This commit is contained in:
@@ -68,9 +68,10 @@ 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 { 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()
|
||||
@@ -124,9 +125,20 @@ onMounted(() => {
|
||||
sseStart()
|
||||
loadInitialUnread()
|
||||
loadExpertAuditStatus()
|
||||
// SSE 推送触发 navbar 角标实时更新 + 医生审核状态刷新 (审核通过事件 = user 立刻看到完整菜单/首页)
|
||||
unsubscribeNewMessage = sseOn('new_message', ({ unread }) => {
|
||||
// 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 状态)
|
||||
|
||||
@@ -86,11 +86,17 @@ export function sseStart() {
|
||||
},
|
||||
onmessage(msg) {
|
||||
// msg.event: 'connected' | 'new_message' | 其它
|
||||
// msg.data: 服务端 .data() 写入的字符串
|
||||
// msg.data: 服务端 .data() 写入的字符串, 格式 {type:'new_message', unread:N, silent:bool}
|
||||
// silent 由后端业务语义决定: false=业务事件触发 (manager 审核/分配/...), true=用户主动操作 (markRead)
|
||||
// 前端按 flag 统一决策弹/不弹, 避免每个业务页面重复判断
|
||||
if (msg.event === 'new_message') {
|
||||
let unread = 0
|
||||
try { unread = JSON.parse(msg.data).unread ?? 0 } catch {}
|
||||
emit('new_message', { unread })
|
||||
let unread = 0, silent = false
|
||||
try {
|
||||
const parsed = JSON.parse(msg.data)
|
||||
unread = parsed.unread ?? 0
|
||||
silent = !!parsed.silent
|
||||
} catch {}
|
||||
emit('new_message', { unread, silent })
|
||||
} else if (msg.event === 'connected') {
|
||||
emit('connected', {})
|
||||
}
|
||||
|
||||
@@ -286,12 +286,13 @@ async function afterLogin(token, displayName) {
|
||||
ElMessage.success(`欢迎,${displayName}(${userTypes.find(u => u.value === role)?.name || role})`)
|
||||
// 角色不在角色首页映射里 → 拒绝
|
||||
if (!roleHome[role]) return router.replace({ name: 'login' })
|
||||
// 带 redirect 回跳 (401/守卫带过来的原页面), 否则跳角色首页
|
||||
// 所有角色统一跳门户首页 '/' (业务方 2026-08-22 要求, 各自角色菜单从导航栏进入)
|
||||
// 带 redirect 回跳 (401/守卫带过来的原页面), 但必须属于当前角色 (否则跳过去被踢回 login)
|
||||
const redirect = route.query.redirect
|
||||
if (redirect) {
|
||||
if (redirect && redirectBelongsToRole(String(redirect), role)) {
|
||||
return router.replace(String(redirect))
|
||||
}
|
||||
router.replace(roleHome[role])
|
||||
router.replace('/')
|
||||
}
|
||||
|
||||
function switchMode(mode) {
|
||||
@@ -355,10 +356,18 @@ const roleHome = {
|
||||
admin: '/admin/workbench',
|
||||
manager: '/manager/workbench',
|
||||
doctor: '/doctor/home',
|
||||
executor: '/executor/meetings',
|
||||
executor: '/executor/overview',
|
||||
sponsor: '/sponsor/home',
|
||||
}
|
||||
|
||||
/** redirect 是否属于当前角色: 防止 doctor 拿到 manager 的 redirect 后跳过去被踢回 login ("现在评审专家登录后不跳转" 的根本原因) */
|
||||
function redirectBelongsToRole(path, role) {
|
||||
if (!path) return false
|
||||
// role=doctor, path=/doctor/xxx → true
|
||||
// role=doctor, path=/manager/xxx → false
|
||||
return path === roleHome[role] || path.startsWith('/' + role + '/')
|
||||
}
|
||||
|
||||
function onRegister() {
|
||||
showRoleModal.value = true
|
||||
registerUserType.value = ''
|
||||
|
||||
@@ -52,7 +52,10 @@
|
||||
<section class="section">
|
||||
<h2 class="section-title">
|
||||
通知消息
|
||||
<a class="more" @click.prevent="$router.push('/doctor/messages')">更多 →</a>
|
||||
<span class="title-actions">
|
||||
<el-button v-if="hasUnread" link size="small" type="primary" @click="markAllRead">全部已读</el-button>
|
||||
<a class="more" @click.prevent="$router.push('/doctor/messages')">更多 →</a>
|
||||
</span>
|
||||
</h2>
|
||||
<ul class="notice-list">
|
||||
<li class="notice-item" :class="{ read: n.read }" v-for="n in notices" :key="n.id" @click="openDetail(n)">
|
||||
@@ -101,9 +104,9 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ElMessage, ElNotification } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import { bizList, listMyMessages, markMessageRead } from '@/api/public'
|
||||
import { bizList, listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
|
||||
import { getMyExpertProfile } from '@/api/business/expert'
|
||||
import { listUnsignedMeetingProtocols } from '@/api/business/meetingAttendee'
|
||||
import { onGlobal } from '@/utils/sseClient'
|
||||
@@ -229,7 +232,7 @@ async function load() {
|
||||
} catch (e) { notices.value = [] }
|
||||
}
|
||||
|
||||
/** 仅重拉通知列表 (SSE new_message 事件触发) */
|
||||
/** 仅重拉通知列表 (SSE new_message 事件触发, AdminLayout 已统一弹 toast, 这里只刷新本页面 list) */
|
||||
async function refreshNotices() {
|
||||
try {
|
||||
notices.value = (await listMyMessages({ limit: 5 })).rows.map((a, i) => ({
|
||||
@@ -237,22 +240,11 @@ async function refreshNotices() {
|
||||
title: a.title,
|
||||
text: a.text,
|
||||
time: a.time,
|
||||
read: a.read // 同上
|
||||
read: a.read
|
||||
}))
|
||||
} catch (e) { /* SSE 重拉失败静默, 用户下次手动刷新可见 */ }
|
||||
}
|
||||
|
||||
/** SSE 推送后弹个右上角通知, 让用户立刻感知到 (#1 验收关键反馈) */
|
||||
function popNewMessageToast(unread) {
|
||||
ElNotification({
|
||||
title: '您有新的通知',
|
||||
message: unread > 1 ? `共 ${unread} 条未读` : '点击下方"通知消息"查看',
|
||||
type: 'success',
|
||||
duration: 5000,
|
||||
position: 'top-right'
|
||||
})
|
||||
}
|
||||
|
||||
// SSE 订阅句柄, 卸载时解订阅避免内存泄漏
|
||||
let unsubscribeNewMessage = null
|
||||
|
||||
@@ -274,14 +266,28 @@ async function openDetail(n) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 全部已读: 调后端 markAllRead (后端会推 silent=true 的 SSE, 角标自动清零) */
|
||||
async function markAllRead() {
|
||||
try {
|
||||
await markAllMessagesRead()
|
||||
// 乐观本地全标已读, 不等 SSE 推送 (push 过来后 refreshNotices 也会全 read)
|
||||
notices.value = notices.value.map(n => ({ ...n, read: true }))
|
||||
} catch (e) {
|
||||
ElMessage.error('全部已读失败, 请重试')
|
||||
}
|
||||
}
|
||||
|
||||
/** 列表里只要有未读就显示"全部已读"按钮 */
|
||||
const hasUnread = computed(() => notices.value.some(n => !n.read))
|
||||
|
||||
onMounted(() => {
|
||||
updateClock()
|
||||
timer = setInterval(updateClock, 60000)
|
||||
load()
|
||||
// 订阅全局总线 (AdminLayout 把 SSE 事件扇出到这里), 跨路由不掉, 但页面 unmount 时仍需解订阅
|
||||
unsubscribeNewMessage = onGlobal(({ unread }) => {
|
||||
// toast 弹窗由 AdminLayout 统一处理 (silent flag), 这里只做本页 list 刷新 + 医生审核状态联动
|
||||
unsubscribeNewMessage = onGlobal(() => {
|
||||
refreshNotices()
|
||||
popNewMessageToast(unread)
|
||||
// manager 审核通过事件过来: 重新拉 store 触发的 audit 状态, 若刚转 approved, 把 pannel 数据拉出来
|
||||
if (!store.expertAuditApproved && store.role === 'doctor') {
|
||||
load()
|
||||
@@ -306,6 +312,7 @@ onBeforeUnmount(() => {
|
||||
.section-title { font-size: 15px; font-weight: 600; color: #1a1a1a; margin: 0 0 12px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.more { font-size: 12px; color: var(--brand-primary); text-decoration: none; font-weight: normal; cursor: pointer; }
|
||||
.more:hover { text-decoration: underline; }
|
||||
.title-actions { display: inline-flex; align-items: center; gap: 12px; font-weight: normal; }
|
||||
.cols-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.cols-row .section { margin-bottom: 16px; }
|
||||
.simple-list { list-style: none; border-top: 1px solid #f0f0f0; }
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="breadcrumb">首页 / 消息通知</div>
|
||||
<div class="breadcrumb">
|
||||
首页 / 消息通知
|
||||
<el-button v-if="hasUnread" link size="small" type="primary" class="mark-all" @click="markAllRead">全部已读</el-button>
|
||||
</div>
|
||||
|
||||
<ul class="notice-list">
|
||||
<li v-for="n in rows" :key="n.id" class="notice-item" :class="{ read: n.read }">
|
||||
@@ -28,12 +31,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { bizList, listMyMessages, markMessageRead } from '@/api/public'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
|
||||
|
||||
const rows = ref([])
|
||||
const detail = ref({})
|
||||
const detailOpen = ref(false)
|
||||
const hasUnread = computed(() => rows.value.some(n => !n.read))
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@@ -83,12 +88,22 @@ function openDetail(n) {
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
try {
|
||||
await markAllMessagesRead()
|
||||
rows.value = rows.value.map(n => ({ ...n, read: true }))
|
||||
} catch (e) {
|
||||
ElMessage.error('全部已读失败, 请重试')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.mark-all { font-size: 13px; }
|
||||
.notice-list { list-style: none; }
|
||||
.notice-item { padding: 14px 0; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 12px; }
|
||||
.notice-item:last-child { border-bottom: none; }
|
||||
|
||||
Reference in New Issue
Block a user