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:
@@ -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