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:
郭庆泰
2026-08-22 15:53:01 +08:00
parent b6ac5b7d41
commit 86e85d02cb
11 changed files with 321 additions and 44 deletions
@@ -40,7 +40,6 @@ public class SysDictDataController extends BaseController
@Autowired
private ISysDictTypeService dictTypeService;
@PreAuthorize("@ss.hasPermi('system:dict:list')")
@GetMapping("/list")
public TableDataInfo list(SysDictData dictData)
{
@@ -50,7 +49,6 @@ public class SysDictDataController extends BaseController
}
@Log(title = "字典数据", businessType = BusinessType.EXPORT)
@PreAuthorize("@ss.hasPermi('system:dict:export')")
@PostMapping("/export")
public void export(HttpServletResponse response, SysDictData dictData)
{
@@ -62,7 +60,6 @@ public class SysDictDataController extends BaseController
/**
* 查询字典数据详细
*/
@PreAuthorize("@ss.hasPermi('system:dict:query')")
@GetMapping(value = "/{dictCode}")
public AjaxResult getInfo(@PathVariable Long dictCode)
{
@@ -16,8 +16,13 @@ import com.ruoyi.business.service.IBizDepartmentService;
import com.ruoyi.business.service.IBizDoctorTitleService;
/**
* 业务字典管理 (科室 + 医生职称)
* 仅管理员可维护; 其它角色可访问 /active 接口拉启用的列表
* 业务字典管理 (科室 + 医生职称).
*
* <p>权限设计 (2026-08-22 用户要求):
* <ul>
* <li><b>读接口</b> (list / active / getById): 开放给所有登录用户, dropdown / 详情都要用</li>
* <li><b>写接口</b> (POST / PUT / DELETE): admin-only, 防止误删字典影响业务</li>
* </ul>
*/
@RestController
@RequestMapping("/business/dict")
@@ -30,7 +35,6 @@ public class BizDictController extends BaseController
// ============== 科室 ==============
@PreAuthorize("@ss.hasRole('admin')")
@GetMapping("/department/list")
public TableDataInfo listDept(BizDepartment entity)
{
@@ -86,7 +90,6 @@ public class BizDictController extends BaseController
// ============== 医生职称 ==============
@PreAuthorize("@ss.hasRole('admin')")
@GetMapping("/title/list")
public TableDataInfo listTitle(BizDoctorTitle entity)
{
@@ -3,7 +3,10 @@ package com.ruoyi.business.controller;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
@@ -19,6 +22,7 @@ import com.ruoyi.business.domain.BizProjectRating;
import com.ruoyi.business.service.IBizProjectService;
import com.ruoyi.business.service.IBizExecutionIntentService;
import com.ruoyi.business.domain.BizProjectSponsorAssign;
import com.ruoyi.business.notify.BizNotifyService;
import com.ruoyi.business.service.IBizProjectAssignService;
import com.ruoyi.business.service.IBizProjectRatingService;
import com.ruoyi.business.service.IBizProjectSponsorAssignService;
@@ -42,6 +46,8 @@ public class BizProjectController extends BaseController
@Autowired
private IBizProjectAssignService bizProjectAssignService;
@Autowired
private BizNotifyService bizNotifyService;
@Autowired
private IBizProjectRatingService bizProjectRatingService;
@Autowired
private IBizProjectSponsorAssignService bizProjectSponsorAssignService;
@@ -175,15 +181,50 @@ public class BizProjectController extends BaseController
{
if (assigns == null) assigns = new ArrayList<>();
bizProjectAssignService.validateSum(projectId, assigns);
// #3 通知去重: 拉旧数据按 execUserId 索引, 同 (execUserId, sessions, amount) → 无变化 → 跳过
// 业务场景: manager 仅调整其他执行人时, 老的 executor 不应被打扰
List<BizProjectAssign> oldList = bizProjectAssignService.selectByProjectId(projectId);
Map<Long, BizProjectAssign> oldByExec = new HashMap<>();
for (BizProjectAssign o : oldList) {
if (o.getExecUserId() != null) oldByExec.put(o.getExecUserId(), o);
}
bizProjectAssignService.deleteByProjectId(projectId);
// #3 通知: 查一次项目名, 避免循环里重复查 DB
BizProject project = bizProjectService.getById(projectId);
String projectName = project != null ? project.getProjectName() : null;
for (BizProjectAssign a : assigns) {
a.setProjectId(projectId);
if (a.getStatus() == null) a.setStatus("0");
bizProjectAssignService.insert(a);
// 跳过空 execUserId 行 (notify 内部也会跳过, 这里直接 continue 省 lookup)
if (a.getExecUserId() == null) continue;
BizProjectAssign old = oldByExec.get(a.getExecUserId());
if (isAssignUnchanged(old, a)) {
logger.debug("[projectAssign] execUserId={} (sessions, amount) 未变, 跳过通知", a.getExecUserId());
continue;
}
// #3 通知被分配的 executor (待办: 去承接). 业务事务回滚时通知自动回滚
bizNotifyService.projectAssignedToExecutor(
a.getExecUserId(), projectId, projectName, a.getSessions(), a.getAmount());
}
return success();
}
/**
* 比较两条 BizProjectAssign 是否对 executor 而言"未变".
* 判定维度: sessions + amount. projectId 隐含相同 (旧数据就是本 projectId 的).
* amount 用 compareTo (100 vs 100.00 都 = 0), 规避 BigDecimal.scale 差异.
*/
private static boolean isAssignUnchanged(BizProjectAssign old, BizProjectAssign n) {
if (old == null) return false; // 新分配或被替换 → 算变化
if (!Objects.equals(old.getSessions(), n.getSessions())) return false;
BigDecimal oa = old.getAmount();
BigDecimal na = n.getAmount();
if (oa == null && na == null) return true;
if (oa == null || na == null) return false;
return oa.compareTo(na) == 0;
}
@Log(title = "项目执行方分配", businessType = BusinessType.DELETE)
@DeleteMapping("/{projectId}/assigns")
public AjaxResult clearAssigns(@PathVariable("projectId") Long projectId)
@@ -1,5 +1,7 @@
package com.ruoyi.business.notify;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -7,6 +9,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.BizMessage;
import com.ruoyi.business.domain.BizProjectPlan;
import com.ruoyi.business.mapper.BizSysUserQueryMapper;
import com.ruoyi.business.service.IBizMessageService;
import com.ruoyi.common.enums.BizAuditStatusEnum;
@@ -54,6 +57,7 @@ public class BizNotifyService
// ====================== bizType 常量 ======================
public static final String BIZ_EXPERT = "expert";
public static final String BIZ_PROJECT = "project";
public static final String BIZ_PROJECT_PLAN = "projectPlan";
public static final String BIZ_MEETING = "meeting";
public static final String BIZ_AGREEMENT = "agreement";
public static final String BIZ_PUBLICITY = "publicity";
@@ -96,4 +100,168 @@ public class BizNotifyService
bizMessageService.insert(msg);
log.info("[notify] expertAuditResult 已发 uid={} ({} → {})", expert.getUserId(), oldAuditStatus, newStatus);
}
/**
* #4 项目策划方案审核结果 → 通知投稿人 (通常是 doctor).
*
* <p>调用方: {@link com.ruoyi.business.service.impl.BizProjectPlanServiceImpl#updateByPrimaryKey}
* 检测到 status 真的变了 (1→2 通过 / 1→3 拒绝) 才调. 其它字段编辑 (plan_name/file/...)
* 不发, 避免噪音. submitterId 为空 (manager/admin 自己创建的方案) → 跳过.
*
* @param plan 方案实体 (含 submitterId / planName / status / auditOpinion)
* @param oldStatus 旧 status, 仅用于日志
*/
public void planAuditResult(BizProjectPlan plan, String oldStatus)
{
if (plan.getSubmitterId() == null) {
log.debug("[notify] planAuditResult: submitterId 为空 (manager/admin 自创方案), 跳过 planId={}", plan.getPlanId());
return;
}
String newStatus = plan.getStatus();
String title;
String content;
String planName = plan.getPlanName() != null ? plan.getPlanName() : "(未命名方案)";
// 关联项目展示: 优先 project_name (LEFT JOIN 拿到的), 兜底 project_no (字符串编号)
String projectLabel = plan.getProjectName() != null && !plan.getProjectName().isEmpty()
? plan.getProjectName()
: (plan.getProjectNo() != null && !plan.getProjectNo().isEmpty() ? plan.getProjectNo() : "(未关联项目)");
String projectSuffix = " (关联项目: " + projectLabel + ")";
if (BizAuditStatusEnum.APPROVED.getCode().equals(newStatus)) {
title = "您的项目策划方案已通过审核";
content = "方案【" + planName + "" + projectSuffix + " 已通过审核。";
} else if (BizAuditStatusEnum.REJECTED.getCode().equals(newStatus)) {
title = "您的项目策划方案审核未通过";
String opinion = plan.getAuditOpinion();
content = "方案【" + planName + "" + projectSuffix + " 审核未通过"
+ (opinion != null && !opinion.isEmpty() ? ", 意见: " + opinion : "");
} else {
// 0→1 (提交) / 其它状态变更 不发结果通知
return;
}
BizMessage msg = new BizMessage();
msg.setReceiverUserId(plan.getSubmitterId());
msg.setMsgType(TYPE_NOTIFY);
msg.setTitle(title);
msg.setContent(content);
msg.setBizType(BIZ_PROJECT_PLAN);
// BizProjectPlan.planId 是 String (雪花 ID), BizMessage.bizId 是 Long, 解析失败置 null
msg.setBizId(parseLongOrNull(plan.getPlanId()));
msg.setCreateBy("system");
bizMessageService.insert(msg);
log.info("[notify] planAuditResult 已发 uid={} planId={} ({} → {})",
plan.getSubmitterId(), plan.getPlanId(), oldStatus, newStatus);
}
/** 字符串 → Long (雪花 ID), 解析失败返回 null */
private static Long parseLongOrNull(String s) {
if (s == null || s.isEmpty()) return null;
try { return Long.parseLong(s); } catch (NumberFormatException e) { return null; }
}
/**
* #5 会议邀请 → 通知新加入的参会人.
*
* <p>调用方: {@link com.ruoyi.business.controller.BizMeetingController#add} / {@code edit},
* 在 attendeeService.insertBatch 后, 对"新加入"的 userId (旧 attendee list 没有的) 逐条调本方法.
* 业务去重由调用方保证 (差集), 本方法不再判断.
*
* @param userId 被邀请人 sys_user.user_id
* @param meetingId biz_meeting.meeting_id
* @param meetingName 会议名 (可空, 兜底 "会议 #ID")
* @param startTime 会议开始时间 (可空, 不展示具体时间)
*/
public void meetingInvitation(Long userId, Long meetingId, String meetingName, Date startTime)
{
if (userId == null) {
log.warn("[notify] meetingInvitation: userId 为空, 跳过 (meetingId={})", meetingId);
return;
}
String name = meetingName != null ? meetingName : ("会议 #" + meetingId);
StringBuilder content = new StringBuilder("您被邀请参加会议【").append(name).append("");
if (startTime != null) {
content.append(", 时间: ").append(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(startTime));
}
content.append("。请登录系统查看详情。");
BizMessage msg = new BizMessage();
msg.setReceiverUserId(userId);
msg.setMsgType(TYPE_NOTIFY); // 会议邀请 = 普通通知 (医生只需知道被邀请, 不是必须立即操作)
msg.setTitle("会议邀请: " + name);
msg.setContent(content.toString());
msg.setBizType(BIZ_MEETING);
msg.setBizId(meetingId);
msg.setCreateBy("system");
bizMessageService.insert(msg);
log.info("[notify] meetingInvitation 已发 uid={} meetingId={}", userId, meetingId);
}
/**
* #6 劳务协议待签 → 通知参会人协议已生成, 请手写签字.
*
* <p>调用方: {@link com.ruoyi.business.controller.BizMeetingAttendeeController#updateLaborProtocol},
* 在 admin/manager 上传协议 PDF URL 成功后调本方法. 通知仅在 URL 非空时触发 (避免空 URL 噪音).
*
* @param userId 被通知人 sys_user.user_id (参会人的 user_id)
* @param attendeeId biz_meeting_attendee.id (用于 bizId 跳转)
* @param meetingId biz_meeting.meeting_id (会议 ID, 用于 title/跳转)
* @param meetingName 会议名 (可空, 兜底)
*/
public void agreementAwaitingSign(Long userId, Long attendeeId, Long meetingId, String meetingName)
{
if (userId == null) {
log.warn("[notify] agreementAwaitingSign: userId 为空, 跳过 (attendeeId={})", attendeeId);
return;
}
String name = meetingName != null ? meetingName : ("会议 #" + meetingId);
BizMessage msg = new BizMessage();
msg.setReceiverUserId(userId);
msg.setMsgType(TYPE_TODO); // 待办: 医生需要签字才能完成
msg.setTitle("劳务协议待签署: " + name);
msg.setContent("会议【" + name + "】的劳务协议已生成, 请登录系统手写签字。");
msg.setBizType(BIZ_AGREEMENT);
msg.setBizId(attendeeId);
msg.setCreateBy("system");
bizMessageService.insert(msg);
log.info("[notify] agreementAwaitingSign 已发 uid={} attendeeId={}", userId, attendeeId);
}
/**
* #3 项目分配执行方 → 通知被分配的 executor (待办: 去承接).
*
* <p>调用方: {@link com.ruoyi.business.controller.BizProjectController#saveAssigns} 循环内.
* 每个 BizProjectAssign 对应一个 MAIN executor 账号, 业务主键 a.getExecUserId().
* 若同一 executor 被分配多次, 会收到多条通知 (每个分配行一条) — 业务上分配按执行方分组,
* 重复分配一般是修正场次/金额, 多次提醒反而更明确.
*
* @param execUserId 被分配的 executor 主账号 user_id (nullable, 跳过)
* @param projectId biz_project.project_id (用于 bizId)
* @param projectName 项目名 (toast 标题用, 可为 null)
* @param sessions 分配场次 (可空)
* @param amount 分配金额 (可空)
*/
public void projectAssignedToExecutor(Long execUserId, Long projectId, String projectName,
Integer sessions, BigDecimal amount)
{
if (execUserId == null) {
log.warn("[notify] projectAssignedToExecutor: execUserId 为空, 跳过 (projectId={})", projectId);
return;
}
String name = projectName != null ? projectName : ("项目 #" + projectId);
String title = "项目分配: " + name;
StringBuilder content = new StringBuilder("您被分配执行【").append(name).append("");
if (sessions != null && sessions > 0) content.append(", 共 ").append(sessions).append(" 场次");
if (amount != null) content.append(", 金额 ¥").append(amount.toPlainString());
content.append("。请登录系统确认承接。");
BizMessage msg = new BizMessage();
msg.setReceiverUserId(execUserId);
msg.setMsgType(TYPE_TODO);
msg.setTitle(title);
msg.setContent(content.toString());
msg.setBizType(BIZ_PROJECT);
msg.setBizId(projectId);
msg.setCreateBy("system");
bizMessageService.insert(msg);
log.info("[notify] projectAssignedToExecutor 已发 uid={} projectId={}", execUserId, projectId);
}
}
@@ -70,16 +70,24 @@ public class BizMessageServiceImpl implements IBizMessageService
q.setIsRead("1");
int n = bizMessageMapper.markRead(q);
// 标记成功后立即推新 unread, navbar 角标自动减 (无需等下次刷新)
// silent=true: 用户主动点已读, 前端不应弹 "您有新的通知" (业务语义由后端决定, 统一到 onGlobal handler)
if (n > 0 && receiverUserId != null) {
int unread = bizMessageMapper.countUnread(receiverUserId);
messageSseService.pushNewMessage(receiverUserId, unread);
messageSseService.pushNewMessage(receiverUserId, unread, true);
}
return n;
}
@Override
public int markAllRead(Long receiverUserId)
{ return bizMessageMapper.markAllRead(receiverUserId); }
{
int n = bizMessageMapper.markAllRead(receiverUserId);
// 全部标记已读后推 SSE, 角标自动清零 (silent=true, 用户主动操作, 前端不弹通知)
if (n > 0 && receiverUserId != null) {
messageSseService.pushNewMessage(receiverUserId, 0, true);
}
return n;
}
@Override
public int deleteByPrimaryKey(Long msgId)
@@ -70,16 +70,21 @@ public class MessageSseService
/**
* 给某用户推一条新消息信号. **绝不抛**, 异常仅 log (hwt-serve 教训).
* 心跳 / 业务推送 / 标记已读 都会调这个.
*
* @param userId 接收人 user_id
* @param unread 当前未读数 (前端用此更新角标)
* @param silent true=静默推送 (用户主动操作触发, 如 markRead), 前端不应弹通知; false=业务事件触发, 可弹
*/
public void pushNewMessage(Long userId, int unread)
public void pushNewMessage(Long userId, int unread, boolean silent)
{
Set<SseEmitter> set = emitters.get(userId);
if (set == null || set.isEmpty()) return;
String payload = "{\"type\":\"new_message\",\"unread\":" + unread + ",\"silent\":" + silent + "}";
for (SseEmitter em : set) {
try {
em.send(SseEmitter.event()
.name("new_message")
.data("{\"type\":\"new_message\",\"unread\":" + unread + "}"));
.data(payload));
} catch (Exception e) {
log.warn("[SSE] push 失败, 自动清理该 emitter uid={} err={}", userId, e.toString());
try { em.completeWithError(e); } catch (Exception ignore) {}
@@ -89,6 +94,12 @@ public class MessageSseService
if (set.isEmpty()) emitters.remove(userId);
}
/** 兼容旧调用: 业务事件触发推送 (silent=false) */
public void pushNewMessage(Long userId, int unread)
{
pushNewMessage(userId, unread, false);
}
/**
* 25s 心跳 — SSE 注释帧 (以 ':' 开头) 不会触发 EventSource.onmessage,
* 仅用来保活防 nginx/proxy 60s 空闲断连.
+15 -3
View File
@@ -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 状态)
+10 -4
View File
@@ -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', {})
}
+13 -4
View File
@@ -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 = ''
+25 -18
View File
@@ -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; }
+19 -4
View File
@@ -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; }