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:
+3
-5
@@ -17,7 +17,7 @@ import com.ruoyi.business.domain.BizMessage;
|
||||
import com.ruoyi.business.service.IBizMessageService;
|
||||
|
||||
/**
|
||||
* 个人消息Controller
|
||||
* 个人消息Controller (SSE 推送端点见 BizMessageSseController)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/business/message")
|
||||
@@ -42,16 +42,14 @@ public class BizMessageController extends BaseController
|
||||
* 当前登录用户的最近消息 (含未读统计)
|
||||
* GET /business/message/my?limit=50
|
||||
* 返回 {rows: [...], unread: 12}
|
||||
* unread 用 countUnread 跟 limit 解耦, SSE 推的也是真值
|
||||
*/
|
||||
@GetMapping("/my")
|
||||
public AjaxResult my(@RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit)
|
||||
{
|
||||
Long uid = SecurityUtils.getUserId();
|
||||
List<BizMessage> rows = bizMessageService.selectMyRecent(uid, limit);
|
||||
int unread = 0;
|
||||
for (BizMessage m : rows) {
|
||||
if ("0".equals(m.getIsRead())) unread++;
|
||||
}
|
||||
int unread = bizMessageService.countUnread(uid);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("rows", rows);
|
||||
data.put("unread", unread);
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.ruoyi.business.controller;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import com.ruoyi.business.sse.MessageSseService;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
|
||||
/**
|
||||
* 个人消息 SSE 推送端点 — 独立 Controller.
|
||||
*
|
||||
* <p>独立成 Controller 的原因: BizMessageController 有 `GET /{msgId:\\d+}` 详情端点,
|
||||
* 即使加正则约束, Spring 6.x 的 PathPattern 在某些边界条件下仍会把 `/stream` 字面量
|
||||
* 路由到 `/{msgId}` 上去, 报 "参数类型不匹配". 拆到独立 Controller 后两条路径
|
||||
* 完全不在同一个映射表里竞争, 零冲突风险.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/business/message")
|
||||
public class BizMessageSseController
|
||||
{
|
||||
@Autowired
|
||||
private MessageSseService messageSseService;
|
||||
|
||||
/**
|
||||
* SSE 订阅当前用户的实时通知信号.
|
||||
* 鉴权: Authorization: Bearer <jwt> — 走 SecurityUtils.getUserId(), 无 token 抛 401.
|
||||
* 推送载荷只含信号 ({type:'new_message', unread:N}), 不带消息体, 防止 SSE 挂了影响正确性.
|
||||
* 心跳 25s 一条 ": ping" 注释帧, 防止 nginx/proxy 60s 空闲断连.
|
||||
*/
|
||||
@GetMapping(value = "/stream", produces = "text/event-stream")
|
||||
public SseEmitter stream()
|
||||
{
|
||||
Long uid = SecurityUtils.getUserId();
|
||||
return messageSseService.subscribe(uid);
|
||||
}
|
||||
}
|
||||
+4
@@ -111,6 +111,10 @@ public class BizRegisterController extends BaseController {
|
||||
expert.setStatus("Y"); // biz_expert.status: Y=正常 N=禁用
|
||||
expertService.insert(expert);
|
||||
|
||||
// 业务规则 (2026-08-22): 专家注册不群发给 manager
|
||||
// 原因: manager 群体可能有几十到上百人, 每条注册都发 N 条通知是浪费
|
||||
// manager 自己到 /manager/experts 列表页拉待审核的即可
|
||||
|
||||
return success("注册成功, 请等待审核").put("userId", userId);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ public interface BizMessageMapper
|
||||
/** 某人的未读 + 最近消息 (按时间倒序, limit 由调用方控制) */
|
||||
List<BizMessage> selectMyRecent(BizMessage entity);
|
||||
|
||||
/** 收件人未读总数 (SSE 推送用, 跟 limit 解耦) */
|
||||
int countUnread(Long receiverUserId);
|
||||
|
||||
int insert(BizMessage entity);
|
||||
|
||||
int updateByPrimaryKey(BizMessage entity);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.ruoyi.business.notify;
|
||||
|
||||
import java.util.List;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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.mapper.BizSysUserQueryMapper;
|
||||
import com.ruoyi.business.service.IBizMessageService;
|
||||
import com.ruoyi.common.enums.BizAuditStatusEnum;
|
||||
import com.ruoyi.system.domain.vo.SysUserExtendVo;
|
||||
|
||||
/**
|
||||
* 业务消息通知门面 (Phase 4).
|
||||
*
|
||||
* <p>提供 N 个业务场景的语义化方法, 每个方法只负责拼 biz_message 然后调
|
||||
* {@link IBizMessageService#insert}, SSE 推送由该 insert 内部已注入的
|
||||
* pushNewMessage 自动触发 (Phase 1 做的事), 不用本服务关心.
|
||||
*
|
||||
* <p>设计要点 (沿用 task_plan.md 决策):
|
||||
* <ul>
|
||||
* <li><b>语义化方法</b>而非裸 notify() — 业务代码调 expertAuditResult() 比拼字典字段清楚</li>
|
||||
* <li><b>只在原业务 insert/update 之后调</b> — 业务事务回滚时通知自动回滚</li>
|
||||
* <li><b>群发 manager 用 {@link BizSysUserQueryMapper#selectActiveByRole}</b> — 避开
|
||||
* ISysUserService.selectUserExtendList 的 @DataScope 切面 (本服务保留 mapper 字段以便未来场景使用)</li>
|
||||
* <li><b>createBy="system"</b> — 系统自动发的通知, 不需要用户身份</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>业务规则 (2026-08-22): 专家注册**不**群发给 manager. 原因: manager 群体可能有
|
||||
* 几十到上百人, 每条注册都发 N 条通知是浪费, manager 自己到 /manager/experts 列表页拉待审核的即可.
|
||||
* 所以本类目前只暴露 #1 (审核结果→专家本人), 不再有 expertRegistered().
|
||||
*/
|
||||
@Service
|
||||
public class BizNotifyService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(BizNotifyService.class);
|
||||
|
||||
@Autowired
|
||||
private IBizMessageService bizMessageService;
|
||||
|
||||
@Autowired
|
||||
private BizSysUserQueryMapper bizSysUserQueryMapper;
|
||||
|
||||
// ====================== msgType 常量 ======================
|
||||
/** 普通通知 */
|
||||
public static final String TYPE_NOTIFY = "1";
|
||||
/** 待办 (需要用户操作) */
|
||||
public static final String TYPE_TODO = "2";
|
||||
/** 系统消息 */
|
||||
public static final String TYPE_SYSTEM = "3";
|
||||
|
||||
// ====================== bizType 常量 ======================
|
||||
public static final String BIZ_EXPERT = "expert";
|
||||
public static final String BIZ_PROJECT = "project";
|
||||
public static final String BIZ_MEETING = "meeting";
|
||||
public static final String BIZ_AGREEMENT = "agreement";
|
||||
public static final String BIZ_PUBLICITY = "publicity";
|
||||
public static final String BIZ_ORG = "org";
|
||||
public static final String BIZ_AUTH = "auth";
|
||||
|
||||
/**
|
||||
* #1 专家审核结果 → 通知专家本人 (auditStatus 1→2 通过 / 1→3 拒绝).
|
||||
*
|
||||
* <p>调用方: BizExpertServiceImpl.updateByPrimaryKey, 检测到 audit_status 真的变了才调.
|
||||
* 其余字段编辑 (姓名/科室/...) 不发.
|
||||
*/
|
||||
public void expertAuditResult(BizExpert expert, String oldAuditStatus)
|
||||
{
|
||||
if (expert.getUserId() == null) {
|
||||
log.warn("[notify] expertAuditResult: expert.userId 为空, 跳过 (expertId={})", expert.getExpertId());
|
||||
return;
|
||||
}
|
||||
String newStatus = expert.getAuditStatus();
|
||||
String title;
|
||||
String content;
|
||||
if (BizAuditStatusEnum.APPROVED.getCode().equals(newStatus)) {
|
||||
title = "您的专家注册已通过审核";
|
||||
content = "感谢您的注册, 现可登录使用全部功能。";
|
||||
} else if (BizAuditStatusEnum.REJECTED.getCode().equals(newStatus)) {
|
||||
title = "您的专家注册审核未通过";
|
||||
content = "请检查提交资料, 必要时联系管理员。";
|
||||
} else {
|
||||
// 其它状态变更 (0→1, 2→3 等) 不发结果通知
|
||||
return;
|
||||
}
|
||||
BizMessage msg = new BizMessage();
|
||||
msg.setReceiverUserId(expert.getUserId());
|
||||
msg.setMsgType(TYPE_NOTIFY);
|
||||
msg.setTitle(title);
|
||||
msg.setContent(content);
|
||||
msg.setBizType(BIZ_EXPERT);
|
||||
msg.setBizId(expert.getExpertId());
|
||||
msg.setCreateBy("system");
|
||||
bizMessageService.insert(msg);
|
||||
log.info("[notify] expertAuditResult 已发 uid={} ({} → {})", expert.getUserId(), oldAuditStatus, newStatus);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ public interface IBizMessageService
|
||||
/** 收件人的最近消息 (含未读) */
|
||||
List<BizMessage> selectMyRecent(Long receiverUserId, Integer limit);
|
||||
|
||||
/** 收件人未读总数 (SSE 推送用) */
|
||||
int countUnread(Long receiverUserId);
|
||||
|
||||
int insert(BizMessage entity);
|
||||
|
||||
int updateByPrimaryKey(BizMessage entity);
|
||||
|
||||
+30
-1
@@ -16,6 +16,7 @@ import com.ruoyi.business.domain.BizExpert;
|
||||
import com.ruoyi.business.domain.dto.ImportResult;
|
||||
import com.ruoyi.business.domain.vo.BizExpertImportVo;
|
||||
import com.ruoyi.business.mapper.BizExpertMapper;
|
||||
import com.ruoyi.business.notify.BizNotifyService;
|
||||
import com.ruoyi.business.service.IBizExpertService;
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
@@ -42,6 +43,9 @@ public class BizExpertServiceImpl implements IBizExpertService
|
||||
@Autowired
|
||||
private SysUserMapper sysUserMapper;
|
||||
|
||||
@Autowired
|
||||
private BizNotifyService bizNotifyService;
|
||||
|
||||
@Override
|
||||
public BizExpert getById(Long expertId)
|
||||
{ return bizExpertMapper.selectByPrimaryKey(expertId); }
|
||||
@@ -107,9 +111,34 @@ public class BizExpertServiceImpl implements IBizExpertService
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 update. #1 触发点 (审核结果通知) 在这里插桩:
|
||||
* - 先读旧 audit_status
|
||||
* - 执行 update
|
||||
* - 仅当 audit_status 真的变化 (1→2 通过 / 1→3 拒绝) 才发通知
|
||||
* - 其它字段编辑 (姓名/科室/...) 不发, 避免噪音
|
||||
*/
|
||||
@Override
|
||||
public int updateByPrimaryKey(BizExpert entity)
|
||||
{ return bizExpertMapper.updateByPrimaryKey(entity); }
|
||||
{
|
||||
String oldAuditStatus = null;
|
||||
if (entity.getExpertId() != null) {
|
||||
BizExpert existed = bizExpertMapper.selectByPrimaryKey(entity.getExpertId());
|
||||
if (existed != null) {
|
||||
oldAuditStatus = existed.getAuditStatus();
|
||||
}
|
||||
}
|
||||
int n = bizExpertMapper.updateByPrimaryKey(entity);
|
||||
if (n > 0 && entity.getAuditStatus() != null
|
||||
&& !entity.getAuditStatus().equals(oldAuditStatus)) {
|
||||
// 重新读一次拿 userId (entity 可能只传了 expertId+auditStatus)
|
||||
BizExpert after = bizExpertMapper.selectByPrimaryKey(entity.getExpertId());
|
||||
if (after != null && after.getUserId() != null) {
|
||||
bizNotifyService.expertAuditResult(after, oldAuditStatus);
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用专家: 同步 biz_expert.status + sys_user.status
|
||||
|
||||
+24
-2
@@ -6,6 +6,7 @@ import org.springframework.stereotype.Service;
|
||||
import com.ruoyi.business.domain.BizMessage;
|
||||
import com.ruoyi.business.mapper.BizMessageMapper;
|
||||
import com.ruoyi.business.service.IBizMessageService;
|
||||
import com.ruoyi.business.sse.MessageSseService;
|
||||
import com.ruoyi.common.utils.id.SnowflakeId;
|
||||
|
||||
@Service
|
||||
@@ -14,6 +15,9 @@ public class BizMessageServiceImpl implements IBizMessageService
|
||||
@Autowired
|
||||
private BizMessageMapper bizMessageMapper;
|
||||
|
||||
@Autowired
|
||||
private MessageSseService messageSseService;
|
||||
|
||||
@Override
|
||||
public BizMessage getById(Long msgId)
|
||||
{ return bizMessageMapper.selectByPrimaryKey(msgId); }
|
||||
@@ -34,11 +38,23 @@ public class BizMessageServiceImpl implements IBizMessageService
|
||||
return bizMessageMapper.selectMyRecent(q);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countUnread(Long receiverUserId)
|
||||
{
|
||||
return bizMessageMapper.countUnread(receiverUserId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int insert(BizMessage entity)
|
||||
{
|
||||
SnowflakeId.injectIfEmpty(entity, "msgId");
|
||||
return bizMessageMapper.insert(entity);
|
||||
int n = bizMessageMapper.insert(entity);
|
||||
// 写完即推: 让接收人前端立即看到新消息信号 (SSE 挂了也不影响正确性)
|
||||
if (n > 0 && entity.getReceiverUserId() != null) {
|
||||
int unread = bizMessageMapper.countUnread(entity.getReceiverUserId());
|
||||
messageSseService.pushNewMessage(entity.getReceiverUserId(), unread);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -52,7 +68,13 @@ public class BizMessageServiceImpl implements IBizMessageService
|
||||
q.setMsgId(msgId);
|
||||
q.setReceiverUserId(receiverUserId);
|
||||
q.setIsRead("1");
|
||||
return bizMessageMapper.markRead(q);
|
||||
int n = bizMessageMapper.markRead(q);
|
||||
// 标记成功后立即推新 unread, navbar 角标自动减 (无需等下次刷新)
|
||||
if (n > 0 && receiverUserId != null) {
|
||||
int unread = bizMessageMapper.countUnread(receiverUserId);
|
||||
messageSseService.pushNewMessage(receiverUserId, unread);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.ruoyi.business.sse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
/**
|
||||
* 个人消息 SSE 推送服务.
|
||||
*
|
||||
* <p>设计要点 (沿用 task_plan.md 决策, 不踩 hwt-serve 的 10 个坑):
|
||||
* <ul>
|
||||
* <li><b>只推信号不推内容</b> — 载荷 {type:'new_message', unread:N}, 不带消息体, DB 是唯一可信源</li>
|
||||
* <li><b>推送失败只 log 绝不抛</b> — hwt-serve 心跳抛 RuntimeException 让定时任务永久死掉, 这里 try/catch 包死</li>
|
||||
* <li><b>Set 而非 List</b> — 同一用户多 tab 同账号只保留一份 emitter, 避免重复推送风暴</li>
|
||||
* <li><b>心跳 25s</b> — nginx/proxy 默认 60s 空闲断, 心跳用 SSE 注释帧 (": ping") 不触发前端 onmessage</li>
|
||||
* <li><b>鉴权靠 SecurityUtils</b> — controller 里 SecurityUtils.getUserId() 抛 401 当 token 无效, 无需额外校验</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
public class MessageSseService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(MessageSseService.class);
|
||||
|
||||
/** 不设服务器端超时, 客户端断开时 send() 抛 IOException → onError 触发清理 */
|
||||
private static final long SSE_TIMEOUT_MS = 0L;
|
||||
|
||||
/** uid -> 该用户所有连接 (一账号多 tab 共享一份 Set) */
|
||||
private final ConcurrentHashMap<Long, Set<SseEmitter>> emitters = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 注册一个新 emitter. 注册完立即发一条 "connected" 帧, 让前端确认链路通.
|
||||
* emitter 在完成 / 超时 / 出错 任一情况下都会从 map 移除.
|
||||
*/
|
||||
public SseEmitter subscribe(Long userId)
|
||||
{
|
||||
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
|
||||
Set<SseEmitter> userSet = emitters.computeIfAbsent(userId,
|
||||
k -> ConcurrentHashMap.newKeySet());
|
||||
userSet.add(emitter);
|
||||
|
||||
Runnable cleanup = () -> {
|
||||
Set<SseEmitter> set = emitters.get(userId);
|
||||
if (set != null) {
|
||||
set.remove(emitter);
|
||||
if (set.isEmpty()) emitters.remove(userId);
|
||||
}
|
||||
};
|
||||
emitter.onCompletion(cleanup);
|
||||
emitter.onTimeout(cleanup);
|
||||
emitter.onError((ex) -> cleanup.run());
|
||||
|
||||
try {
|
||||
// 立即发一条 connected 帧, 前端收到即可视为订阅成功
|
||||
emitter.send(SseEmitter.event().name("connected").data("ok"));
|
||||
} catch (IOException e) {
|
||||
log.warn("[SSE] 初始 connected 帧发送失败 uid={} err={}", userId, e.toString());
|
||||
cleanup.run();
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
log.info("[SSE] subscribe uid={} currentSetSize={}", userId, userSet.size());
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给某用户推一条新消息信号. **绝不抛**, 异常仅 log (hwt-serve 教训).
|
||||
* 心跳 / 业务推送 / 标记已读 都会调这个.
|
||||
*/
|
||||
public void pushNewMessage(Long userId, int unread)
|
||||
{
|
||||
Set<SseEmitter> set = emitters.get(userId);
|
||||
if (set == null || set.isEmpty()) return;
|
||||
for (SseEmitter em : set) {
|
||||
try {
|
||||
em.send(SseEmitter.event()
|
||||
.name("new_message")
|
||||
.data("{\"type\":\"new_message\",\"unread\":" + unread + "}"));
|
||||
} catch (Exception e) {
|
||||
log.warn("[SSE] push 失败, 自动清理该 emitter uid={} err={}", userId, e.toString());
|
||||
try { em.completeWithError(e); } catch (Exception ignore) {}
|
||||
set.remove(em);
|
||||
}
|
||||
}
|
||||
if (set.isEmpty()) emitters.remove(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 25s 心跳 — SSE 注释帧 (以 ':' 开头) 不会触发 EventSource.onmessage,
|
||||
* 仅用来保活防 nginx/proxy 60s 空闲断连.
|
||||
* 整个方法 try/catch 包死, 单次失败不影响后续调度 (Spring TaskScheduler 每次调用是独立的).
|
||||
*/
|
||||
@Scheduled(fixedRate = 25_000)
|
||||
public void heartbeat()
|
||||
{
|
||||
try {
|
||||
for (Map.Entry<Long, Set<SseEmitter>> e : emitters.entrySet()) {
|
||||
Long uid = e.getKey();
|
||||
Set<SseEmitter> set = e.getValue();
|
||||
if (set == null) continue;
|
||||
for (SseEmitter em : set) {
|
||||
try {
|
||||
em.send(SseEmitter.event().comment("ping"));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[SSE] 心跳失败, 自动清理 uid={} err={}", uid, ex.toString());
|
||||
try { em.completeWithError(ex); } catch (Exception ignore) {}
|
||||
set.remove(em);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[SSE] heartbeat 调度异常 (不影响下次调度)", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,12 @@
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="countUnread" resultType="int" parameterType="Long">
|
||||
select count(*) from biz_message
|
||||
where receiver_user_id = #{receiverUserId}
|
||||
and is_read = '0'
|
||||
</select>
|
||||
|
||||
<insert id="insert" parameterType="BizMessage" useGeneratedKeys="true" keyProperty="msgId">
|
||||
insert into biz_message
|
||||
<trim prefix="(" suffix=")" suffixOverrides=",">
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.ruoyi.framework.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
|
||||
|
||||
/**
|
||||
* 全局宽松占位符解析器 — 2026-08-22 加.
|
||||
*
|
||||
* <p>Spring Framework 7 的 PlaceholderParser 改成严格模式:
|
||||
* @Value("${prop}") 找不到就抛 PlaceholderResolutionException, 启动失败.
|
||||
* 即便加 :default 也不兜底 (实测 guoju0808 的 xss.excludes / token.header 都炸).
|
||||
*
|
||||
* <p>修法: 全局注册一个 ignoreUnresolvablePlaceholders=true 的
|
||||
* PropertySourcesPlaceholderConfigurer, 行为回退到 Spring 6 时代 — 找不到就
|
||||
* 留作字面量 ${...}, 不抛. 各 @Value 调用方按需自取:
|
||||
* <ul>
|
||||
* <li>要么 yml 里确实配了 (现在的 FilterConfig / TokenService 都是这种情况)</li>
|
||||
* <li>要么代码层用 Environment.getProperty(key, default) 自兜底 (更安全)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>bean name 必须叫 propertySourcesPlaceholderConfigurer, 覆盖 Spring Boot
|
||||
* 自动注册的默认那个.
|
||||
*/
|
||||
@Configuration
|
||||
public class PlaceholderConfig
|
||||
{
|
||||
@Bean
|
||||
public static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer()
|
||||
{
|
||||
PropertySourcesPlaceholderConfigurer c = new PropertySourcesPlaceholderConfigurer();
|
||||
c.setIgnoreUnresolvablePlaceholders(true);
|
||||
c.setIgnoreResourceNotFound(true);
|
||||
return c;
|
||||
}
|
||||
}
|
||||
Generated
+6
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.6.0",
|
||||
@@ -496,6 +497,11 @@
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
|
||||
},
|
||||
"node_modules/@microsoft/fetch-event-source": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz",
|
||||
"integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.6.0",
|
||||
|
||||
@@ -37,6 +37,15 @@
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<el-button text @click="$router.push('/')">门户首页</el-button>
|
||||
<!-- 消息入口 (admin/manager/doctor/sponsor/executor 各自路由, 通过 meta.noticePath 配置) -->
|
||||
<el-badge v-if="unreadCount > 0" :value="unreadCount" :max="99" class="notice-badge">
|
||||
<el-button text @click="goNotice">
|
||||
<el-icon><Bell /></el-icon>
|
||||
</el-button>
|
||||
</el-badge>
|
||||
<el-button v-else text @click="goNotice">
|
||||
<el-icon><Bell /></el-icon>
|
||||
</el-button>
|
||||
<el-dropdown trigger="click" @command="onCommand">
|
||||
<span class="user-name">{{ store.user?.userName || store.user?.displayName }}<el-icon><CaretBottom /></el-icon></span>
|
||||
<template #dropdown>
|
||||
@@ -55,10 +64,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
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 { listMyMessages } from '@/api/public'
|
||||
import { getMyExpertProfile } from '@/api/business/expert'
|
||||
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()
|
||||
@@ -66,6 +78,70 @@ const router = useRouter()
|
||||
const store = useUserStore()
|
||||
const role = computed(() => route.meta?.role || store.role)
|
||||
|
||||
// ===== 全局未读数 (navbar bell 角标) =====
|
||||
// AdminLayout 是唯一永驻组件, 由它统一维护 unreadCount,
|
||||
// 任何路由切换都不会丢订阅, 永远正确
|
||||
const unreadCount = ref(0)
|
||||
|
||||
/** 拉一次 /my 拿初始 unread, 刷新页面后角标也对 */
|
||||
async function loadInitialUnread() {
|
||||
try {
|
||||
const r = await listMyMessages({ limit: 1 })
|
||||
unreadCount.value = r.unread || 0
|
||||
} catch (e) { unreadCount.value = 0 }
|
||||
}
|
||||
|
||||
/** 各角色点 bell 跳到自己的消息页 */
|
||||
const NOTICE_PATH = {
|
||||
admin: '/admin/workbench', // admin 暂未建独立消息页, 落工作台
|
||||
manager: '/manager/workbench', // manager 同上 (Phase 7 补 /manager/messages)
|
||||
doctor: '/doctor/messages',
|
||||
executor: '/executor/overview', // executor 同 admin/manager 暂用工作台
|
||||
sponsor: '/sponsor/home' // sponsor 同上
|
||||
}
|
||||
function goNotice() {
|
||||
const p = NOTICE_PATH[role.value] || '/'
|
||||
router.push(p)
|
||||
}
|
||||
|
||||
/** doctor 角色: 拉 biz_expert.auditStatus 同步到 store, 控制侧栏 menu + Home pannel */
|
||||
async function loadExpertAuditStatus() {
|
||||
if (store.role !== 'doctor') return
|
||||
try {
|
||||
const { data } = await getMyExpertProfile()
|
||||
store.setExpertAuditApproved(data && data.auditStatus === '2')
|
||||
} catch (e) {
|
||||
// 404/未注册等都视为未通过
|
||||
store.setExpertAuditApproved(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 登录后启动 SSE 订阅, 组件卸载时关闭 (单例连接, 不重复开)
|
||||
let unsubscribeNewMessage = null
|
||||
let stopWatchRoute = null
|
||||
onMounted(() => {
|
||||
if (!store.isLogin) return
|
||||
sseStart()
|
||||
loadInitialUnread()
|
||||
loadExpertAuditStatus()
|
||||
// SSE 推送触发 navbar 角标实时更新 + 医生审核状态刷新 (审核通过事件 = user 立刻看到完整菜单/首页)
|
||||
unsubscribeNewMessage = sseOn('new_message', ({ unread }) => {
|
||||
unreadCount.value = unread
|
||||
if (store.role === 'doctor') loadExpertAuditStatus()
|
||||
})
|
||||
// 路由切换时重拉一次 (用户从消息页点列表已读后回首页, 角标要同步减; 医生进入新页面也刷新 audit 状态)
|
||||
// immediate:false, 启动时不重复 (onMounted 已拉过)
|
||||
stopWatchRoute = watch(() => route.path, () => {
|
||||
loadInitialUnread()
|
||||
loadExpertAuditStatus()
|
||||
})
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
sseStop()
|
||||
if (unsubscribeNewMessage) unsubscribeNewMessage()
|
||||
if (stopWatchRoute) stopWatchRoute()
|
||||
})
|
||||
|
||||
const ROLE_LABEL = { manager: '合规人员', admin: '后台管理员', doctor: '评审专家', executor: '执行人', sponsor: '支持方' }
|
||||
const roleLabel = computed(() => ROLE_LABEL[role.value] || '工作台')
|
||||
|
||||
@@ -104,10 +180,11 @@ const MENU = {
|
||||
],
|
||||
doctor: [
|
||||
{ path: '/doctor/home', title: '首页', icon: House },
|
||||
{ path: '/doctor/meetings', title: '我参与的会议', icon: Calendar },
|
||||
{ path: '/doctor/projects', title: '我报名的项目', icon: Document },
|
||||
// 以下 3 项仅审核通过 (audit_status='2') 才显示, 由 menu.filter 用 requireAuditApproved 过滤
|
||||
{ path: '/doctor/meetings', title: '我参与的会议', icon: Calendar, requireAuditApproved: true },
|
||||
{ path: '/doctor/projects', title: '我报名的项目', icon: Document, requireAuditApproved: true },
|
||||
{ path: '/doctor/messages', title: '消息通知', icon: Bell },
|
||||
{ path: '/doctor/submissions', title: '我的项目设计投稿', icon: EditPen },
|
||||
{ path: '/doctor/submissions', title: '我的项目设计投稿', icon: EditPen, requireAuditApproved: true },
|
||||
{ path: '/doctor/account', title: '账号信息', icon: User }
|
||||
],
|
||||
executor: [
|
||||
@@ -126,10 +203,14 @@ const MENU = {
|
||||
{ path: '/sponsor/account', title: '账号信息', icon: Setting }
|
||||
]
|
||||
}
|
||||
const menu = computed(() => (MENU[role.value] || []).filter(item => !item.requireMain || store.isMain))
|
||||
const menu = computed(() => (MENU[role.value] || []).filter(item =>
|
||||
(!item.requireMain || store.isMain) &&
|
||||
(!item.requireAuditApproved || store.expertAuditApproved)
|
||||
))
|
||||
|
||||
const logout = async () => {
|
||||
try { await logoutApi() } catch {}
|
||||
sseStop()
|
||||
store.logout()
|
||||
router.replace('/login')
|
||||
}
|
||||
@@ -180,6 +261,8 @@ const onCommand = (cmd) => { if (cmd === 'logout') logout() }
|
||||
.page-title { font-size: 16px; font-weight: 600; }
|
||||
.topbar-right { display: flex; align-items: center; gap: 12px; }
|
||||
.user-name { cursor: pointer; display: inline-flex; align-items: center; gap: 4px; }
|
||||
/* navbar bell 角标: 紧凑, 不撑大 topbar */
|
||||
.notice-badge :deep(.el-badge__content) { transform: translate(50%, -10%); }
|
||||
.main { background: #f5f7fa; padding: 16px; }
|
||||
/* 手机页面 (hideMenu): 白底 + 0 padding, 让 form 直接撑满 */
|
||||
.main-full { background: #fff; padding: 0; }
|
||||
|
||||
@@ -3,7 +3,14 @@ import { defineStore } from 'pinia'
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('ry_token') || '',
|
||||
user: JSON.parse(localStorage.getItem('ry_user') || 'null')
|
||||
user: JSON.parse(localStorage.getItem('ry_user') || 'null'),
|
||||
/**
|
||||
* 当前用户 (doctor 角色) 的专家审核状态
|
||||
* true = 审核通过 (audit_status='2'), false = 待审核/被拒绝/未注册
|
||||
* 用于侧栏 menu 和首页 pannel 的可见性控制
|
||||
* 非 doctor 角色保持 false (无意义)
|
||||
*/
|
||||
expertAuditApproved: false
|
||||
}),
|
||||
getters: {
|
||||
role: (s) => s.user?.role || '',
|
||||
@@ -28,9 +35,13 @@ export const useUserStore = defineStore('user', {
|
||||
this.user = user
|
||||
localStorage.setItem('ry_user', JSON.stringify(user))
|
||||
},
|
||||
setExpertAuditApproved(v) {
|
||||
this.expertAuditApproved = !!v
|
||||
},
|
||||
logout() {
|
||||
this.token = ''
|
||||
this.user = null
|
||||
this.expertAuditApproved = false
|
||||
localStorage.removeItem('ry_token')
|
||||
localStorage.removeItem('ry_user')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// SSE 客户端封装 — 用 @microsoft/fetch-event-source (可设 Authorization header, 原生 EventSource 不行)
|
||||
// 设计要点 (沿用 task_plan.md 决策):
|
||||
// - 单例连接: App 启动时调 sseStart() 一次, 退出时 sseStop()
|
||||
// - 推送只推信号: new_message 事件 = 重拉 /business/message/my, 内容由 DB 提供
|
||||
// - 断线重连: fetch-event-source 内置无限重试, 无需自己实现指数退避
|
||||
// - 鉴权: Authorization: Bearer <localStorage['ry_token']>, 避免 token 进 URL/nginx log
|
||||
// - 后端心跳 ": ping" (SSE 注释帧) 不触发 onmessage, 仅保活防代理 60s 断连
|
||||
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||
|
||||
const SSE_PATH = '/dev-api/business/message/stream'
|
||||
|
||||
// 监听器表 (允许多组件订阅)
|
||||
const listeners = { new_message: new Set(), connected: new Set() }
|
||||
|
||||
// SSE 连接单例状态 (避免重复订阅)
|
||||
let ctrl = null // AbortController, 用于关闭连接
|
||||
let started = false // 是否已启动订阅
|
||||
|
||||
// ====================== 全局事件总线 ======================
|
||||
// 设计: 路由切换时 page 组件会 unmount, 直接 sseOn('new_message') 会丢订阅.
|
||||
// AdminLayout 是唯一永驻的组件, 由它统一把 SSE 事件转发到 'global:new_message' 总线,
|
||||
// 各 page 订阅总线, 这样 navbar bell 角标永远在, 切页面不影响.
|
||||
const globalBus = new Set()
|
||||
|
||||
function emit(eventName, payload) {
|
||||
const set = listeners[eventName]
|
||||
if (set) {
|
||||
for (const fn of set) {
|
||||
try { fn(payload) } catch (e) { console.error(`[SSE] listener error on ${eventName}`, e) }
|
||||
}
|
||||
}
|
||||
// SSE 的 new_message 同时扇出到全局总线, 由 AdminLayout 维护 unreadCount, page 订阅刷新列表
|
||||
if (eventName === 'new_message') {
|
||||
for (const fn of globalBus) {
|
||||
try { fn(payload) } catch (e) { console.error(`[bus] listener error`, e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function on(eventName, handler) {
|
||||
const set = listeners[eventName]
|
||||
if (!set) throw new Error(`[SSE] unknown event: ${eventName}`)
|
||||
set.add(handler)
|
||||
return () => set.delete(handler) // 返回 unsubscribe
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅全局总线 (SSE 事件的二级 fan-out, 用于 page 组件, 跨路由不掉)
|
||||
* 推荐各业务页面用这个, 不要直接 sseOn('new_message') — 后者绑在 AdminLayout 里更合理
|
||||
*/
|
||||
export function onGlobal(handler) {
|
||||
globalBus.add(handler)
|
||||
return () => globalBus.delete(handler)
|
||||
}
|
||||
|
||||
export function sseStart() {
|
||||
if (started) return
|
||||
started = true
|
||||
|
||||
const token = localStorage.getItem('ry_token')
|
||||
if (!token) {
|
||||
console.warn('[SSE] 无 token, 跳过订阅 (用户未登录)')
|
||||
started = false
|
||||
return
|
||||
}
|
||||
|
||||
ctrl = new AbortController()
|
||||
fetchEventSource(SSE_PATH, {
|
||||
signal: ctrl.signal,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
openWhenHidden: true, // 切到后台 tab 也保持, 减少重连风暴
|
||||
onopen(response) {
|
||||
if (response.ok) {
|
||||
console.info('[SSE] connected')
|
||||
return
|
||||
}
|
||||
// 401 等鉴权失败: 立刻停, fetch-event-source 会一直重试会刷屏
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
console.warn(`[SSE] 鉴权失败 status=${response.status}, 停止重试`)
|
||||
ctrl.abort()
|
||||
started = false
|
||||
return
|
||||
}
|
||||
throw new Error(`SSE open failed: ${response.status}`)
|
||||
},
|
||||
onmessage(msg) {
|
||||
// msg.event: 'connected' | 'new_message' | 其它
|
||||
// msg.data: 服务端 .data() 写入的字符串
|
||||
if (msg.event === 'new_message') {
|
||||
let unread = 0
|
||||
try { unread = JSON.parse(msg.data).unread ?? 0 } catch {}
|
||||
emit('new_message', { unread })
|
||||
} else if (msg.event === 'connected') {
|
||||
emit('connected', {})
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
console.info('[SSE] closed')
|
||||
},
|
||||
onerror(err) {
|
||||
console.warn('[SSE] error', err)
|
||||
// fetch-event-source 内部会自动重试, 这里只需要记日志, **不要 throw** (throw 会中断重试)
|
||||
}
|
||||
}).catch((e) => {
|
||||
if (e?.name !== 'AbortError') console.error('[SSE] fetchEventSource error', e)
|
||||
started = false
|
||||
})
|
||||
}
|
||||
|
||||
export function sseStop() {
|
||||
if (ctrl) { try { ctrl.abort() } catch {} ; ctrl = null }
|
||||
started = false
|
||||
console.info('[SSE] stopped')
|
||||
}
|
||||
@@ -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