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:
@@ -14,8 +14,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 待参加的会议 + 待签署的协议 -->
|
||||
<div class="cols-row">
|
||||
<!-- 待参加的会议 + 待签署的协议: 仅审核通过的医生可见 -->
|
||||
<div class="cols-row" v-if="store.expertAuditApproved">
|
||||
<div class="section">
|
||||
<h2 class="section-title">
|
||||
待参加的会议
|
||||
@@ -55,7 +55,7 @@
|
||||
<a class="more" @click.prevent="$router.push('/doctor/messages')">更多 →</a>
|
||||
</h2>
|
||||
<ul class="notice-list">
|
||||
<li class="notice-item" :class="{ read: n.read }" v-for="n in notices" :key="n.id" @click="n.read = true">
|
||||
<li class="notice-item" :class="{ read: n.read }" v-for="n in notices" :key="n.id" @click="openDetail(n)">
|
||||
<div class="dot"></div>
|
||||
<div class="body">
|
||||
<div class="title">{{ n.title }}</div>
|
||||
@@ -67,6 +67,19 @@
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 消息详情 dialog (点列表项触发) -->
|
||||
<el-dialog v-model="detailOpen" :title="detail.title || '消息详情'" width="520px" align-center destroy-on-close>
|
||||
<div class="detail-body">
|
||||
<div class="meta">
|
||||
<span class="time">{{ detail.time }}</span>
|
||||
</div>
|
||||
<div class="text">{{ detail.text || detail.content || '-' }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="detailOpen = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 二维码弹窗 -->
|
||||
<el-dialog v-model="qrcodeOpen" title="扫码签署劳务协议" width="380px" align-center destroy-on-close>
|
||||
<div class="qrcode-wrap">
|
||||
@@ -88,11 +101,12 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElNotification } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import { bizList, listMyMessages } from '@/api/public'
|
||||
import { bizList, listMyMessages, markMessageRead } from '@/api/public'
|
||||
import { getMyExpertProfile } from '@/api/business/expert'
|
||||
import { listUnsignedMeetingProtocols } from '@/api/business/meetingAttendee'
|
||||
import { onGlobal } from '@/utils/sseClient'
|
||||
|
||||
const store = useUserStore()
|
||||
|
||||
@@ -176,28 +190,32 @@ function updateClock() {
|
||||
}
|
||||
|
||||
async function load() {
|
||||
// 待参加会议
|
||||
try {
|
||||
const { data } = await bizList('meeting', { pageNum: 1, pageSize: 5 })
|
||||
upcomingMeetings.value = (data?.rows || []).slice(0, 5)
|
||||
} catch (e) { upcomingMeetings.value = [] }
|
||||
|
||||
// 当前用户的专家真实姓名 (用于欢迎栏)
|
||||
// 当前用户的专家真实姓名 (用于欢迎栏, 不论审核状态都需要)
|
||||
try {
|
||||
const { data } = await getMyExpertProfile()
|
||||
expertName.value = data?.name || ''
|
||||
} catch (e) { expertName.value = '' }
|
||||
|
||||
// 待签署协议 (v3: 改走 biz_meeting_attendee, 任一未签即显示)
|
||||
try {
|
||||
const { data } = await listUnsignedMeetingProtocols()
|
||||
pendingAgreements.value = (data || []).slice(0, 5).map(s => ({
|
||||
id: s.id, // biz_meeting_attendee.id (用于签署接口)
|
||||
meetingId: s.meetingId,
|
||||
meetingName: s.meetingName,
|
||||
startTime: s.startTime
|
||||
}))
|
||||
} catch (e) { pendingAgreements.value = [] }
|
||||
// 待参加会议 + 待签署协议: 仅审核通过的医生才拉 (未通过时 2 个 pannel 隐藏)
|
||||
if (store.expertAuditApproved) {
|
||||
try {
|
||||
const { data } = await bizList('meeting', { pageNum: 1, pageSize: 5 })
|
||||
upcomingMeetings.value = (data?.rows || []).slice(0, 5)
|
||||
} catch (e) { upcomingMeetings.value = [] }
|
||||
|
||||
try {
|
||||
const { data } = await listUnsignedMeetingProtocols()
|
||||
pendingAgreements.value = (data || []).slice(0, 5).map(s => ({
|
||||
id: s.id,
|
||||
meetingId: s.meetingId,
|
||||
meetingName: s.meetingName,
|
||||
startTime: s.startTime
|
||||
}))
|
||||
} catch (e) { pendingAgreements.value = [] }
|
||||
} else {
|
||||
upcomingMeetings.value = []
|
||||
pendingAgreements.value = []
|
||||
}
|
||||
|
||||
// 通知
|
||||
try {
|
||||
@@ -206,17 +224,74 @@ async function load() {
|
||||
title: a.title,
|
||||
text: a.text,
|
||||
time: a.time,
|
||||
read: a.read || i >= 2
|
||||
read: a.read // 严格按 DB is_read, 不再用 i>=2 硬编码"第3条算旧"误导用户
|
||||
}))
|
||||
} catch (e) { notices.value = [] }
|
||||
}
|
||||
|
||||
/** 仅重拉通知列表 (SSE new_message 事件触发) */
|
||||
async function refreshNotices() {
|
||||
try {
|
||||
notices.value = (await listMyMessages({ limit: 5 })).rows.map((a, i) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
text: a.text,
|
||||
time: a.time,
|
||||
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
|
||||
|
||||
// ===== 消息详情 dialog =====
|
||||
const detailOpen = ref(false)
|
||||
const detail = ref({})
|
||||
|
||||
/** 点通知项: 打开 dialog + 调 markRead (后端会再触发 SSE 推新未读, navbar 角标自动减) */
|
||||
async function openDetail(n) {
|
||||
detail.value = n
|
||||
detailOpen.value = true
|
||||
if (!n.read) {
|
||||
try {
|
||||
await markMessageRead(n.id)
|
||||
n.read = true // 本地乐观更新, 避免再调 refreshNotices 闪屏
|
||||
} catch (e) {
|
||||
// markRead 失败不阻塞 dialog, 用户下次点还会重试
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateClock()
|
||||
timer = setInterval(updateClock, 60000)
|
||||
load()
|
||||
// 订阅全局总线 (AdminLayout 把 SSE 事件扇出到这里), 跨路由不掉, 但页面 unmount 时仍需解订阅
|
||||
unsubscribeNewMessage = onGlobal(({ unread }) => {
|
||||
refreshNotices()
|
||||
popNewMessageToast(unread)
|
||||
// manager 审核通过事件过来: 重新拉 store 触发的 audit 状态, 若刚转 approved, 把 pannel 数据拉出来
|
||||
if (!store.expertAuditApproved && store.role === 'doctor') {
|
||||
load()
|
||||
}
|
||||
})
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
if (unsubscribeNewMessage) unsubscribeNewMessage()
|
||||
})
|
||||
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -262,4 +337,9 @@ onBeforeUnmount(() => { if (timer) clearInterval(timer) })
|
||||
.qrcode-tip { font-size: 14px; color: #1a1a1a; margin-top: 16px; }
|
||||
.qrcode-hint { font-size: 12px; color: #999; margin-top: 6px; }
|
||||
.qrcode-copy { display: inline-block; margin-top: 16px; font-size: 13px; }
|
||||
|
||||
/* 消息详情 dialog */
|
||||
.detail-body .meta { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
|
||||
.detail-body .time { font-size: 12px; color: #8c8c8c; }
|
||||
.detail-body .text { font-size: 14px; color: #1a1a1a; line-height: 1.6; white-space: pre-wrap; }
|
||||
</style>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { bizList, listMyMessages } from '@/api/public'
|
||||
import { bizList, listMyMessages, markMessageRead } from '@/api/public'
|
||||
|
||||
const rows = ref([])
|
||||
const detail = ref({})
|
||||
@@ -76,6 +76,11 @@ function formatAgo(t) {
|
||||
function openDetail(n) {
|
||||
detail.value = n
|
||||
detailOpen.value = true
|
||||
if (!n.read) {
|
||||
// 乐观本地置已读 + 后端 markRead (后端触发 SSE 推新未读, navbar 角标自动减)
|
||||
n.read = true
|
||||
markMessageRead(n.id).catch(() => { n.read = false })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
Reference in New Issue
Block a user