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:
-3
@@ -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)
|
||||
{
|
||||
|
||||
+7
-4
@@ -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)
|
||||
{
|
||||
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -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 空闲断连.
|
||||
|
||||
@@ -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