From 86e85d02cb366e249d5e957b02e1f6ced04a7529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=BA=86=E6=B3=B0?= <12369755+htcloud1@user.noreply.gitee.com> Date: Sat, 22 Aug 2026 15:53:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(msg):=20SSE=20=E5=AE=9E=E6=97=B6=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E4=BD=93=E7=B3=BB=20(#1-#6)=20+=20dict=20=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=9D=83=E9=99=90=E5=88=86=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 防止误删 --- .../system/SysDictDataController.java | 3 - .../controller/BizDictController.java | 11 +- .../controller/BizProjectController.java | 41 +++++ .../business/notify/BizNotifyService.java | 168 ++++++++++++++++++ .../service/impl/BizMessageServiceImpl.java | 12 +- .../ruoyi/business/sse/MessageSseService.java | 15 +- ry-vue3/src/layout/AdminLayout.vue | 18 +- ry-vue3/src/utils/sseClient.js | 14 +- ry-vue3/src/views/auth/Login.vue | 17 +- ry-vue3/src/views/doctor/Home.vue | 43 +++-- ry-vue3/src/views/doctor/Messages.vue | 23 ++- 11 files changed, 321 insertions(+), 44 deletions(-) diff --git a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDictDataController.java b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDictDataController.java index f65492b..94c32dc 100644 --- a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDictDataController.java +++ b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDictDataController.java @@ -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) { diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizDictController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizDictController.java index 4da8694..3122384 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizDictController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizDictController.java @@ -16,8 +16,13 @@ import com.ruoyi.business.service.IBizDepartmentService; import com.ruoyi.business.service.IBizDoctorTitleService; /** - * 业务字典管理 (科室 + 医生职称) - * 仅管理员可维护; 其它角色可访问 /active 接口拉启用的列表 + * 业务字典管理 (科室 + 医生职称). + * + *

权限设计 (2026-08-22 用户要求): + *

*/ @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) { diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java index 6d5ee57..7f0754e 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java @@ -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 oldList = bizProjectAssignService.selectByProjectId(projectId); + Map 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) diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java index aeb9513..09f0499 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java @@ -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). + * + *

调用方: {@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 会议邀请 → 通知新加入的参会人. + * + *

调用方: {@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 劳务协议待签 → 通知参会人协议已生成, 请手写签字. + * + *

调用方: {@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 (待办: 去承接). + * + *

调用方: {@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); + } } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMessageServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMessageServiceImpl.java index 595c810..fb560f4 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMessageServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMessageServiceImpl.java @@ -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) diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sse/MessageSseService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sse/MessageSseService.java index 6e66d2d..56f5729 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sse/MessageSseService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sse/MessageSseService.java @@ -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 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 空闲断连. diff --git a/ry-vue3/src/layout/AdminLayout.vue b/ry-vue3/src/layout/AdminLayout.vue index 6c4d38d..3960952 100644 --- a/ry-vue3/src/layout/AdminLayout.vue +++ b/ry-vue3/src/layout/AdminLayout.vue @@ -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 状态) diff --git a/ry-vue3/src/utils/sseClient.js b/ry-vue3/src/utils/sseClient.js index 8bbf8d8..f0400d1 100644 --- a/ry-vue3/src/utils/sseClient.js +++ b/ry-vue3/src/utils/sseClient.js @@ -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', {}) } diff --git a/ry-vue3/src/views/auth/Login.vue b/ry-vue3/src/views/auth/Login.vue index 3b10435..79511b7 100644 --- a/ry-vue3/src/views/auth/Login.vue +++ b/ry-vue3/src/views/auth/Login.vue @@ -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 = '' diff --git a/ry-vue3/src/views/doctor/Home.vue b/ry-vue3/src/views/doctor/Home.vue index aa7e748..8858a13 100644 --- a/ry-vue3/src/views/doctor/Home.vue +++ b/ry-vue3/src/views/doctor/Home.vue @@ -52,7 +52,10 @@

通知消息 - 更多 → + + 全部已读 + 更多 → +

  • @@ -101,9 +104,9 @@