feat: 完成消息通知中心全功能开发

1. 新增消息通知相关实体、Mapper、Service、控制器与前端页面
2. 实现审批通知、报价到期提醒等通知发送逻辑
3. 完成通知菜单配置与路由注册
4. 修复通知数据与跳转路径问题
5. 新增配套SQL脚本与定时任务
This commit is contained in:
2026-06-21 04:20:44 +08:00
parent 41b2e3e772
commit 8bdb8d7c23
27 changed files with 1817 additions and 132 deletions

View File

@@ -0,0 +1,127 @@
package com.ruoyi.web.controller.bid;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.system.domain.bid.BizNotifyMessage;
import com.ruoyi.system.domain.bid.BizNotifyRule;
import com.ruoyi.system.service.bid.IBizNotifyMessageService;
import com.ruoyi.system.service.bid.IBizNotifyRuleService;
import com.ruoyi.common.utils.SecurityUtils;
@RestController
@RequestMapping("/bid/notify")
public class BizNotifyController extends BaseController {
@Autowired
private IBizNotifyMessageService notifyMessageService;
@Autowired
private IBizNotifyRuleService notifyRuleService;
// ==================== 消息通知接口 ====================
@PreAuthorize("@ss.hasPermi('bid:notify:list')")
@GetMapping("/list")
public TableDataInfo list(BizNotifyMessage query) {
// 强制按当前用户过滤
query.setUserId(SecurityUtils.getUserId());
startPage();
List<BizNotifyMessage> list = notifyMessageService.selectNotifyMessageList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('bid:notify:list')")
@GetMapping("/unreadCount")
public AjaxResult unreadCount() {
return success(notifyMessageService.selectUnreadCount(SecurityUtils.getUserId()));
}
@PreAuthorize("@ss.hasPermi('bid:notify:list')")
@GetMapping("/topUnread")
public AjaxResult topUnread(@RequestParam(defaultValue = "5") int limit) {
return success(notifyMessageService.selectTopUnread(SecurityUtils.getUserId(), limit));
}
@PreAuthorize("@ss.hasPermi('bid:notify:list')")
@GetMapping("/topNotify")
public AjaxResult topNotify(@RequestParam(defaultValue = "10") int limit) {
return success(notifyMessageService.selectTopNotify(SecurityUtils.getUserId(), limit));
}
@PreAuthorize("@ss.hasPermi('bid:notify:list')")
@GetMapping("/stats")
public AjaxResult stats() {
return success(notifyMessageService.selectNotifyStats(SecurityUtils.getUserId()));
}
@PreAuthorize("@ss.hasPermi('bid:notify:query')")
@GetMapping("/{messageId}")
public AjaxResult getInfo(@PathVariable("messageId") Long messageId) {
return success(notifyMessageService.selectNotifyMessageById(messageId));
}
@PreAuthorize("@ss.hasPermi('bid:notify:read')")
@PutMapping("/read/{messageId}")
public AjaxResult markRead(@PathVariable("messageId") Long messageId) {
return toAjax(notifyMessageService.markAsRead(messageId, SecurityUtils.getUserId()));
}
@PreAuthorize("@ss.hasPermi('bid:notify:read')")
@PutMapping("/readAll")
public AjaxResult markAllRead() {
return toAjax(notifyMessageService.markAllAsRead(SecurityUtils.getUserId()));
}
@PreAuthorize("@ss.hasPermi('bid:notify:remove')")
@Log(title = "消息通知", businessType = BusinessType.DELETE)
@DeleteMapping("/{messageIds}")
public AjaxResult remove(@PathVariable Long[] messageIds) {
return toAjax(notifyMessageService.deleteNotifyMessageByIds(messageIds));
}
// ==================== 通知规则接口 ====================
@PreAuthorize("@ss.hasPermi('bid:notify:rule')")
@GetMapping("/rule/list")
public TableDataInfo ruleList(BizNotifyRule query) {
startPage();
List<BizNotifyRule> list = notifyRuleService.selectNotifyRuleList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('bid:notify:rule')")
@GetMapping("/rule/{ruleId}")
public AjaxResult getRule(@PathVariable("ruleId") Long ruleId) {
return success(notifyRuleService.selectNotifyRuleById(ruleId));
}
@PreAuthorize("@ss.hasPermi('bid:notify:rule')")
@Log(title = "通知规则", businessType = BusinessType.INSERT)
@PostMapping("/rule")
public AjaxResult addRule(@RequestBody BizNotifyRule rule) {
rule.setCreateBy(getUsername());
return toAjax(notifyRuleService.insertNotifyRule(rule));
}
@PreAuthorize("@ss.hasPermi('bid:notify:rule')")
@Log(title = "通知规则", businessType = BusinessType.UPDATE)
@PutMapping("/rule")
public AjaxResult editRule(@RequestBody BizNotifyRule rule) {
rule.setUpdateBy(getUsername());
return toAjax(notifyRuleService.updateNotifyRule(rule));
}
@PreAuthorize("@ss.hasPermi('bid:notify:rule')")
@Log(title = "通知规则", businessType = BusinessType.DELETE)
@DeleteMapping("/rule/{ruleIds}")
public AjaxResult removeRule(@PathVariable Long[] ruleIds) {
return toAjax(notifyRuleService.deleteNotifyRuleByIds(ruleIds));
}
}

View File

@@ -0,0 +1,52 @@
package com.ruoyi.system.domain.bid;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 消息通知 biz_notify_message
*/
public class BizNotifyMessage extends BaseEntity {
private static final long serialVersionUID = 1L;
private Long messageId;
private Long tenantId;
private Long userId;
private String noticeType;
private Integer priority;
private String title;
private String content;
private String bizType;
private Long bizId;
private String bizUrl;
private String isRead;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date readTime;
public Long getMessageId() { return messageId; }
public void setMessageId(Long messageId) { this.messageId = messageId; }
public Long getTenantId() { return tenantId; }
public void setTenantId(Long tenantId) { this.tenantId = tenantId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getNoticeType() { return noticeType; }
public void setNoticeType(String noticeType) { this.noticeType = noticeType; }
public Integer getPriority() { return priority; }
public void setPriority(Integer priority) { this.priority = priority; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getBizType() { return bizType; }
public void setBizType(String bizType) { this.bizType = bizType; }
public Long getBizId() { return bizId; }
public void setBizId(Long bizId) { this.bizId = bizId; }
public String getBizUrl() { return bizUrl; }
public void setBizUrl(String bizUrl) { this.bizUrl = bizUrl; }
public String getIsRead() { return isRead; }
public void setIsRead(String isRead) { this.isRead = isRead; }
public Date getReadTime() { return readTime; }
public void setReadTime(Date readTime) { this.readTime = readTime; }
}

View File

@@ -0,0 +1,36 @@
package com.ruoyi.system.domain.bid;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 通知规则配置 biz_notify_rule
*/
public class BizNotifyRule extends BaseEntity {
private static final long serialVersionUID = 1L;
private Long ruleId;
private Long tenantId;
private String ruleName;
private String noticeType;
private String bizType;
private String triggerCondition;
private Integer advanceDays;
private String enabled;
public Long getRuleId() { return ruleId; }
public void setRuleId(Long ruleId) { this.ruleId = ruleId; }
public Long getTenantId() { return tenantId; }
public void setTenantId(Long tenantId) { this.tenantId = tenantId; }
public String getRuleName() { return ruleName; }
public void setRuleName(String ruleName) { this.ruleName = ruleName; }
public String getNoticeType() { return noticeType; }
public void setNoticeType(String noticeType) { this.noticeType = noticeType; }
public String getBizType() { return bizType; }
public void setBizType(String bizType) { this.bizType = bizType; }
public String getTriggerCondition() { return triggerCondition; }
public void setTriggerCondition(String triggerCondition) { this.triggerCondition = triggerCondition; }
public Integer getAdvanceDays() { return advanceDays; }
public void setAdvanceDays(Integer advanceDays) { this.advanceDays = advanceDays; }
public String getEnabled() { return enabled; }
public void setEnabled(String enabled) { this.enabled = enabled; }
}

View File

@@ -16,4 +16,24 @@ public interface BizApprovalActionMapper {
@Param("pk") String pk,
@Param("statusCol") String statusCol,
@Param("id") Long id);
/** 查询业务单据的 create_by提交人用户名 */
java.util.Map<String, Object> selectBizCreateInfo(@Param("table") String table,
@Param("pk") String pk,
@Param("id") Long id);
/** 查询采购订单详情(含供应商名称、询价标题、金额) */
java.util.Map<String, Object> selectPurchaseOrderInfo(@Param("id") Long id);
/** 查询客户报价详情(含客户名称、询价标题、金额) */
java.util.Map<String, Object> selectClientQuoteInfo(@Param("id") Long id);
/** 查询供应商报价详情(含供应商名称、询价标题、金额) */
java.util.Map<String, Object> selectQuotationInfo(@Param("id") Long id);
/** 查询发货单详情(含供应商/客户名称、金额) */
java.util.Map<String, Object> selectDeliveryOrderInfo(@Param("id") Long id);
/** 查询订单异议详情(含供应商名称、采购单号) */
java.util.Map<String, Object> selectObjectionInfo(@Param("id") Long id);
}

View File

@@ -0,0 +1,32 @@
package com.ruoyi.system.mapper.bid;
import java.util.List;
import java.util.Map;
import com.ruoyi.system.domain.bid.BizNotifyMessage;
import org.apache.ibatis.annotations.Param;
public interface BizNotifyMessageMapper {
public List<BizNotifyMessage> selectNotifyMessageList(BizNotifyMessage query);
public BizNotifyMessage selectNotifyMessageById(Long messageId);
public int insertNotifyMessage(BizNotifyMessage message);
public int batchInsertNotifyMessage(@Param("list") List<BizNotifyMessage> list);
public int updateNotifyMessage(BizNotifyMessage message);
public int markAsRead(@Param("messageId") Long messageId, @Param("userId") Long userId);
public int markAllAsRead(@Param("userId") Long userId);
public int deleteNotifyMessageByIds(@Param("ids") Long[] ids);
public int selectUnreadCount(@Param("userId") Long userId);
public List<Map<String, Object>> selectNotifyStats(@Param("userId") Long userId);
public List<BizNotifyMessage> selectTopUnread(@Param("userId") Long userId, @Param("limit") int limit);
public List<BizNotifyMessage> selectTopNotify(@Param("userId") Long userId, @Param("limit") int limit);
}

View File

@@ -0,0 +1,18 @@
package com.ruoyi.system.mapper.bid;
import java.util.List;
import com.ruoyi.system.domain.bid.BizNotifyRule;
public interface BizNotifyRuleMapper {
public List<BizNotifyRule> selectNotifyRuleList(BizNotifyRule query);
public BizNotifyRule selectNotifyRuleById(Long ruleId);
public int insertNotifyRule(BizNotifyRule rule);
public int updateNotifyRule(BizNotifyRule rule);
public int deleteNotifyRuleByIds(Long[] ids);
public List<BizNotifyRule> selectEnabledRules();
}

View File

@@ -0,0 +1,73 @@
package com.ruoyi.system.service.bid;
import java.util.List;
import java.util.Map;
import com.ruoyi.system.domain.bid.BizNotifyMessage;
/**
* 消息通知服务接口
*/
public interface IBizNotifyMessageService {
public List<BizNotifyMessage> selectNotifyMessageList(BizNotifyMessage query);
public BizNotifyMessage selectNotifyMessageById(Long messageId);
public int insertNotifyMessage(BizNotifyMessage message);
public int batchInsertNotifyMessage(List<BizNotifyMessage> list);
public int markAsRead(Long messageId, Long userId);
public int markAllAsRead(Long userId);
public int deleteNotifyMessageByIds(Long[] ids);
public int selectUnreadCount(Long userId);
public List<Map<String, Object>> selectNotifyStats(Long userId);
public List<BizNotifyMessage> selectTopUnread(Long userId, int limit);
/**
* 查询最近N条通知含已读和未读用于铃铛面板
*/
public List<BizNotifyMessage> selectTopNotify(Long userId, int limit);
/**
* 发送审批结果通知
* @param bizType 业务类型
* @param bizId 业务ID
* @param bizTitle 业务标题
* @param approved 是否通过
* @param reason 驳回原因
* @param submitUserId 提交人用户ID
* @param approverName 审批人姓名
*/
public void sendApprovalNotification(String bizType, Long bizId, String bizTitle,
boolean approved, String reason, Long submitUserId, String approverName);
/**
* 发送报价到期提醒
* @param quotationId 报价单ID
* @param quotationNo 报价单号
* @param supplierName 供应商名称
* @param daysRemaining 剩余天数
* @param userId 接收人用户ID
*/
public void sendQuotationExpireNotification(Long quotationId, String quotationNo,
String supplierName, int daysRemaining, Long userId);
/**
* 发送报价到期提醒(含详情)
* @param quotationId 报价单ID
* @param quotationNo 报价单号
* @param supplierName 供应商名称
* @param rfqTitle 询价标题
* @param totalAmount 报价金额
* @param daysRemaining 剩余天数
* @param userId 接收人用户ID
*/
public void sendQuotationExpireNotification(Long quotationId, String quotationNo,
String supplierName, String rfqTitle, java.math.BigDecimal totalAmount,
int daysRemaining, Long userId);
}

View File

@@ -0,0 +1,13 @@
package com.ruoyi.system.service.bid;
import java.util.List;
import com.ruoyi.system.domain.bid.BizNotifyRule;
public interface IBizNotifyRuleService {
public List<BizNotifyRule> selectNotifyRuleList(BizNotifyRule query);
public BizNotifyRule selectNotifyRuleById(Long ruleId);
public int insertNotifyRule(BizNotifyRule rule);
public int updateNotifyRule(BizNotifyRule rule);
public int deleteNotifyRuleByIds(Long[] ids);
public List<BizNotifyRule> selectEnabledRules();
}

View File

@@ -4,6 +4,11 @@ import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.system.mapper.bid.BizApprovalActionMapper;
import com.ruoyi.system.service.bid.IBizApprovalActionService;
import com.ruoyi.system.service.bid.IBizApprovalConfigService;
import com.ruoyi.system.service.bid.IBizNotifyMessageService;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.system.domain.bid.BizApprovalConfig;
import com.ruoyi.system.domain.bid.BizNotifyMessage;
import com.ruoyi.system.mapper.SysUserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -18,24 +23,26 @@ public class BizApprovalActionServiceImpl implements IBizApprovalActionService {
@Autowired private BizApprovalActionMapper mapper;
@Autowired private IBizApprovalConfigService configService;
@Autowired private IBizNotifyMessageService notifyMessageService;
@Autowired private SysUserMapper sysUserMapper;
/** 业务类型 -> 表/主键/状态列/审批通过的目标状态/可提交的初始状态 */
/** 业务类型 -> 表/主键/状态列/审批通过的目标状态/可提交的初始状态/业务名称 */
static class Meta {
final String table, pk, statusCol, approvedStatus;
final String table, pk, statusCol, approvedStatus, bizName;
final List<String> draftStatuses;
Meta(String t, String pk, String sc, String approved, List<String> drafts) {
Meta(String t, String pk, String sc, String approved, List<String> drafts, String bizName) {
this.table = t; this.pk = pk; this.statusCol = sc;
this.approvedStatus = approved; this.draftStatuses = drafts;
this.approvedStatus = approved; this.draftStatuses = drafts; this.bizName = bizName;
}
}
private static final Map<String, Meta> META = new HashMap<>();
static {
META.put("PURCHASE_ORDER", new Meta("biz_purchase_order", "po_id", "status", "confirmed", Arrays.asList("draft")));
META.put("CLIENT_QUOTE", new Meta("biz_client_quote", "quote_id", "status", "confirmed", Arrays.asList("draft")));
META.put("QUOTATION", new Meta("biz_quotation", "quotation_id", "status", "accepted", Arrays.asList("draft", "submitted")));
META.put("DELIVERY_ORDER", new Meta("biz_delivery_order", "do_id", "delivery_status", "confirmed", Arrays.asList("pending")));
META.put("ORDER_OBJECTION", new Meta("biz_order_objection", "objection_id", "status", "resolved", Arrays.asList("pending")));
META.put("PURCHASE_ORDER", new Meta("biz_purchase_order", "po_id", "status", "confirmed", Arrays.asList("draft"), "采购订单"));
META.put("CLIENT_QUOTE", new Meta("biz_client_quote", "quote_id", "status", "confirmed", Arrays.asList("draft"), "客户报价"));
META.put("QUOTATION", new Meta("biz_quotation", "quotation_id", "status", "accepted", Arrays.asList("draft", "submitted"), "供应商报价"));
META.put("DELIVERY_ORDER", new Meta("biz_delivery_order", "do_id", "delivery_status", "confirmed", Arrays.asList("pending"), "发货单"));
META.put("ORDER_OBJECTION", new Meta("biz_order_objection", "objection_id", "status", "resolved", Arrays.asList("pending"), "订单异议"));
}
private Meta meta(String bizType) {
@@ -49,6 +56,13 @@ public class BizApprovalActionServiceImpl implements IBizApprovalActionService {
Meta m = meta(bizType);
int rows = mapper.updateStatus(m.table, m.pk, m.statusCol, id, "10", m.draftStatuses, username);
if (rows == 0) throw new ServiceException("当前状态不允许提交审批");
// 通知审批人:有新的待审批申请
try {
sendSubmitNotification(bizType, m, id, username);
} catch (Exception e) {
// 通知发送失败不影响审批操作
}
return rows;
}
@@ -61,6 +75,13 @@ public class BizApprovalActionServiceImpl implements IBizApprovalActionService {
int rows = mapper.updateStatus(m.table, m.pk, m.statusCol, id,
m.approvedStatus, Collections.singletonList("10"), username);
if (rows == 0) throw new ServiceException("单据非审批中, 无法通过");
// 发送审批通过通知给提交人
try {
sendApprovalNotification(bizType, m, id, true, null, username);
} catch (Exception e) {
// 通知发送失败不影响审批操作
}
return rows;
}
@@ -73,6 +94,203 @@ public class BizApprovalActionServiceImpl implements IBizApprovalActionService {
int rows = mapper.updateStatus(m.table, m.pk, m.statusCol, id,
"rejected", Collections.singletonList("10"), username);
if (rows == 0) throw new ServiceException("单据非审批中, 无法驳回");
// 发送审批驳回通知给提交人
try {
sendApprovalNotification(bizType, m, id, false, reason, username);
} catch (Exception e) {
// 通知发送失败不影响审批操作
}
return rows;
}
/**
* 查询业务详情,返回统一格式: bizNo, partnerName, bizTitle, totalAmount, currency, createTime, createBy
*/
private Map<String, Object> getBizDetail(String bizType, Long bizId) {
switch (bizType) {
case "PURCHASE_ORDER": return mapper.selectPurchaseOrderInfo(bizId);
case "CLIENT_QUOTE": return mapper.selectClientQuoteInfo(bizId);
case "QUOTATION": return mapper.selectQuotationInfo(bizId);
case "DELIVERY_ORDER": return mapper.selectDeliveryOrderInfo(bizId);
case "ORDER_OBJECTION": return mapper.selectObjectionInfo(bizId);
default: return null;
}
}
/**
* 构建审批通知内容(包含单号、客户/供应商、金额等关键信息)
*/
private String buildApprovalContent(String bizType, Meta m, Map<String, Object> detail,
boolean approved, String reason, String approverName) {
StringBuilder sb = new StringBuilder();
sb.append("您提交的【").append(m.bizName).append("");
// 单号
if (detail.get("bizNo") != null) {
sb.append("(单号: ").append(detail.get("bizNo")).append("");
}
if (approved) {
sb.append("已审批通过。");
} else {
sb.append("被驳回。");
}
// 客户/供应商名称
String partnerLabel = getPartnerLabel(bizType);
if (detail.get("partnerName") != null) {
sb.append(partnerLabel).append(": ").append(detail.get("partnerName")).append("");
}
// 发货单额外显示客户名称
if ("DELIVERY_ORDER".equals(bizType) && detail.get("clientName") != null) {
sb.append("客户: ").append(detail.get("clientName")).append("");
}
// 询价标题/异议原因
if (detail.get("bizTitle") != null) {
sb.append("标题: ").append(detail.get("bizTitle")).append("");
}
// 金额
if (detail.get("totalAmount") != null) {
sb.append("金额: ").append(detail.get("totalAmount"));
if (detail.get("currency") != null) {
sb.append(" ").append(detail.get("currency"));
}
sb.append("");
}
sb.append("审批人: ").append(approverName);
// 驳回原因
if (!approved && reason != null && !reason.isEmpty()) {
sb.append(";驳回原因: ").append(reason);
}
return sb.toString();
}
/**
* 构建提交审批通知内容(发给审批人)
*/
private String buildSubmitContent(String bizType, Meta m, Map<String, Object> detail, String submitterName) {
StringBuilder sb = new StringBuilder();
sb.append("您有新的【").append(m.bizName).append("】审批申请待处理。");
if (detail.get("bizNo") != null) {
sb.append("单号: ").append(detail.get("bizNo")).append("");
}
String partnerLabel = getPartnerLabel(bizType);
if (detail.get("partnerName") != null) {
sb.append(partnerLabel).append(": ").append(detail.get("partnerName")).append("");
}
if ("DELIVERY_ORDER".equals(bizType) && detail.get("clientName") != null) {
sb.append("客户: ").append(detail.get("clientName")).append("");
}
if (detail.get("bizTitle") != null) {
sb.append("标题: ").append(detail.get("bizTitle")).append("");
}
if (detail.get("totalAmount") != null) {
sb.append("金额: ").append(detail.get("totalAmount"));
if (detail.get("currency") != null) {
sb.append(" ").append(detail.get("currency"));
}
sb.append("");
}
sb.append("提交人: ").append(submitterName);
return sb.toString();
}
/** 根据业务类型返回关联方标签 */
private String getPartnerLabel(String bizType) {
switch (bizType) {
case "PURCHASE_ORDER":
case "QUOTATION":
case "DELIVERY_ORDER":
case "ORDER_OBJECTION":
return "供应商";
case "CLIENT_QUOTE":
return "客户";
default:
return "关联方";
}
}
/**
* 发送审批结果通知
*/
private void sendApprovalNotification(String bizType, Meta m, Long bizId, boolean approved, String reason, String approverName) {
Map<String, Object> detail = getBizDetail(bizType, bizId);
if (detail == null || detail.get("createBy") == null) return;
String createBy = (String) detail.get("createBy");
SysUser submitUser = sysUserMapper.selectUserByUserName(createBy);
if (submitUser == null) return;
String bizNo = detail.get("bizNo") != null ? String.valueOf(detail.get("bizNo")) : String.valueOf(bizId);
String titlePrefix = approved ? "审批通过" : "审批驳回";
String title = titlePrefix + ": " + m.bizName + " " + bizNo;
String content = buildApprovalContent(bizType, m, detail, approved, reason, approverName);
BizNotifyMessage msg = new BizNotifyMessage();
msg.setUserId(submitUser.getUserId());
msg.setNoticeType("approval");
msg.setPriority(approved ? 1 : 2);
msg.setBizType(bizType);
msg.setBizId(bizId);
msg.setBizUrl(getBizUrl(bizType, bizId));
msg.setCreateBy(approverName);
msg.setTitle(title);
msg.setContent(content);
msg.setIsRead("0");
notifyMessageService.insertNotifyMessage(msg);
}
/**
* 发送提交审批通知给审批人
*/
private void sendSubmitNotification(String bizType, Meta m, Long bizId, String submitterName) {
BizApprovalConfig config = configService.selectByBizType(bizType);
if (config == null || config.getUserIds() == null || config.getUserIds().isEmpty()) return;
Map<String, Object> detail = getBizDetail(bizType, bizId);
String bizNo = (detail != null && detail.get("bizNo") != null) ? String.valueOf(detail.get("bizNo")) : String.valueOf(bizId);
String title = "审批待处理: " + m.bizName + " " + bizNo;
String content = (detail != null) ? buildSubmitContent(bizType, m, detail, submitterName)
: "您有新的【" + m.bizName + "】审批申请待处理,提交人: " + submitterName;
for (Long approverUserId : config.getUserIds()) {
if (approverUserId == null) continue;
BizNotifyMessage msg = new BizNotifyMessage();
msg.setUserId(approverUserId);
msg.setNoticeType("approval");
msg.setPriority(1);
msg.setBizType(bizType);
msg.setBizId(bizId);
msg.setCreateBy(submitterName);
msg.setTitle(title);
msg.setContent(content);
msg.setBizUrl(getBizUrl(bizType, bizId));
msg.setIsRead("0");
notifyMessageService.insertNotifyMessage(msg);
}
}
private String getBizUrl(String bizType, Long bizId) {
if (bizType == null || bizId == null) return null;
switch (bizType) {
case "PURCHASE_ORDER": return "/quote/purchaseorder?id=" + bizId;
case "CLIENT_QUOTE": return "/bid/clientquote/detail?id=" + bizId;
case "QUOTATION": return "/quote/quotation?quotationId=" + bizId;
case "DELIVERY_ORDER": return "/bid/order/pending?id=" + bizId;
case "ORDER_OBJECTION": return "/bid/order/objection?id=" + bizId;
default: return null;
}
}
}

View File

@@ -0,0 +1,155 @@
package com.ruoyi.system.service.bid.impl;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.system.domain.bid.BizNotifyMessage;
import com.ruoyi.system.mapper.bid.BizNotifyMessageMapper;
import com.ruoyi.system.service.bid.IBizNotifyMessageService;
@Service
public class BizNotifyMessageServiceImpl implements IBizNotifyMessageService {
@Autowired
private BizNotifyMessageMapper notifyMessageMapper;
@Override
public List<BizNotifyMessage> selectNotifyMessageList(BizNotifyMessage query) {
return notifyMessageMapper.selectNotifyMessageList(query);
}
@Override
public BizNotifyMessage selectNotifyMessageById(Long messageId) {
return notifyMessageMapper.selectNotifyMessageById(messageId);
}
@Override
public int insertNotifyMessage(BizNotifyMessage message) {
return notifyMessageMapper.insertNotifyMessage(message);
}
@Override
public int batchInsertNotifyMessage(List<BizNotifyMessage> list) {
return notifyMessageMapper.batchInsertNotifyMessage(list);
}
@Override
public int markAsRead(Long messageId, Long userId) {
return notifyMessageMapper.markAsRead(messageId, userId);
}
@Override
public int markAllAsRead(Long userId) {
return notifyMessageMapper.markAllAsRead(userId);
}
@Override
public int deleteNotifyMessageByIds(Long[] ids) {
return notifyMessageMapper.deleteNotifyMessageByIds(ids);
}
@Override
public int selectUnreadCount(Long userId) {
return notifyMessageMapper.selectUnreadCount(userId);
}
@Override
public List<Map<String, Object>> selectNotifyStats(Long userId) {
return notifyMessageMapper.selectNotifyStats(userId);
}
@Override
public List<BizNotifyMessage> selectTopUnread(Long userId, int limit) {
return notifyMessageMapper.selectTopUnread(userId, limit);
}
@Override
public List<BizNotifyMessage> selectTopNotify(Long userId, int limit) {
return notifyMessageMapper.selectTopNotify(userId, limit);
}
@Override
public void sendApprovalNotification(String bizType, Long bizId, String bizTitle,
boolean approved, String reason, Long submitUserId, String approverName) {
BizNotifyMessage msg = new BizNotifyMessage();
msg.setUserId(submitUserId);
msg.setNoticeType("approval");
msg.setPriority(approved ? 1 : 2);
msg.setBizType(bizType);
msg.setBizId(bizId);
msg.setCreateBy(approverName);
if (approved) {
msg.setTitle("审批通过: " + bizTitle);
msg.setContent("您提交的【" + bizTitle + "】已审批通过。审批人: " + approverName);
} else {
msg.setTitle("审批驳回: " + bizTitle);
msg.setContent("您提交的【" + bizTitle + "】被驳回。审批人: " + approverName + ";驳回原因: " + (reason != null ? reason : ""));
}
msg.setIsRead("0");
msg.setBizUrl(getBizUrl(bizType, bizId));
notifyMessageMapper.insertNotifyMessage(msg);
}
@Override
public void sendQuotationExpireNotification(Long quotationId, String quotationNo,
String supplierName, int daysRemaining, Long userId) {
sendQuotationExpireNotification(quotationId, quotationNo, supplierName, null, null, daysRemaining, userId);
}
@Override
public void sendQuotationExpireNotification(Long quotationId, String quotationNo,
String supplierName, String rfqTitle, java.math.BigDecimal totalAmount,
int daysRemaining, Long userId) {
BizNotifyMessage msg = new BizNotifyMessage();
msg.setUserId(userId);
msg.setNoticeType("quotation_expire");
msg.setBizType("QUOTATION");
msg.setBizId(quotationId);
msg.setBizUrl("/quote/quotation?quotationId=" + quotationId);
msg.setCreateBy("system");
if (daysRemaining <= 0) {
msg.setPriority(2);
msg.setTitle("报价已过期: " + quotationNo);
} else if (daysRemaining <= 1) {
msg.setPriority(2);
msg.setTitle("报价今日到期: " + quotationNo);
} else {
msg.setPriority(1);
msg.setTitle("报价即将到期: " + quotationNo);
}
StringBuilder content = new StringBuilder();
content.append("供应商【").append(supplierName).append("】的报价单【").append(quotationNo).append("");
if (daysRemaining <= 0) {
content.append("已过期,请及时处理。");
} else {
content.append("将在").append(daysRemaining).append("天后到期。");
}
if (rfqTitle != null && !rfqTitle.isEmpty()) {
content.append("询价标题: ").append(rfqTitle).append("");
}
if (totalAmount != null) {
content.append("报价金额: ").append(totalAmount).append("");
}
msg.setContent(content.toString());
msg.setIsRead("0");
notifyMessageMapper.insertNotifyMessage(msg);
}
private String getBizUrl(String bizType, Long bizId) {
if (bizType == null || bizId == null) return null;
switch (bizType) {
case "PURCHASE_ORDER": return "/quote/purchaseorder?id=" + bizId;
case "CLIENT_QUOTE": return "/bid/clientquote/detail?id=" + bizId;
case "QUOTATION": return "/quote/quotation?quotationId=" + bizId;
case "DELIVERY_ORDER": return "/bid/order/pending?id=" + bizId;
case "ORDER_OBJECTION": return "/bid/order/objection?id=" + bizId;
default: return null;
}
}
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.system.service.bid.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.system.domain.bid.BizNotifyRule;
import com.ruoyi.system.mapper.bid.BizNotifyRuleMapper;
import com.ruoyi.system.service.bid.IBizNotifyRuleService;
@Service
public class BizNotifyRuleServiceImpl implements IBizNotifyRuleService {
@Autowired
private BizNotifyRuleMapper notifyRuleMapper;
@Override
public List<BizNotifyRule> selectNotifyRuleList(BizNotifyRule query) {
return notifyRuleMapper.selectNotifyRuleList(query);
}
@Override
public BizNotifyRule selectNotifyRuleById(Long ruleId) {
return notifyRuleMapper.selectNotifyRuleById(ruleId);
}
@Override
public int insertNotifyRule(BizNotifyRule rule) {
return notifyRuleMapper.insertNotifyRule(rule);
}
@Override
public int updateNotifyRule(BizNotifyRule rule) {
return notifyRuleMapper.updateNotifyRule(rule);
}
@Override
public int deleteNotifyRuleByIds(Long[] ids) {
return notifyRuleMapper.deleteNotifyRuleByIds(ids);
}
@Override
public List<BizNotifyRule> selectEnabledRules() {
return notifyRuleMapper.selectEnabledRules();
}
}

View File

@@ -0,0 +1,95 @@
package com.ruoyi.system.task;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.system.service.bid.IBizNotifyMessageService;
import com.ruoyi.system.mapper.bid.BizQuotationMapper;
import com.ruoyi.system.domain.bid.BizQuotation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 定时任务 - 报价到期提醒
* 每日执行,检查即将到期和已过期的报价单,发送站内通知
*
* Quartz 调用示例: quotationExpireTask.checkQuotationExpire()
* 推荐表达式: 0 0 9 * * ? (每天上午9点执行)
*/
@Component("quotationExpireTask")
public class QuotationExpireTask {
private static final Logger log = LoggerFactory.getLogger(QuotationExpireTask.class);
@Autowired
private BizQuotationMapper quotationMapper;
@Autowired
private IBizNotifyMessageService notifyMessageService;
@Autowired
private SysUserMapper sysUserMapper;
/**
* 每日报价到期检查
* 检查所有未完成报价单(草稿、审批中),基于 submitTime + validDays 计算到期日,
* 在到期前3天、1天、到期当天发送提醒。
*/
public void checkQuotationExpire() {
log.info("=== 开始执行报价到期检查 ===");
// 查询所有报价单(不限制状态),在循环中过滤已完成的
BizQuotation query = new BizQuotation();
List<BizQuotation> quotations = quotationMapper.selectBizQuotationList(query);
int remindCount = 0;
for (BizQuotation q : quotations) {
// 跳过已完成的报价单
if ("accepted".equals(q.getStatus()) || "rejected".equals(q.getStatus())) {
continue;
}
// 必须设置了有效期
if (q.getValidDays() == null || q.getValidDays() <= 0) continue;
// 使用 submitTime 计算到期,草稿单据用 createTime 回退
java.util.Date baseTime = q.getSubmitTime() != null ? q.getSubmitTime() : q.getCreateTime();
if (baseTime == null) continue;
long expireTime = baseTime.getTime() + (long) q.getValidDays() * 24 * 60 * 60 * 1000L;
long now = System.currentTimeMillis();
int daysRemaining = (int) Math.ceil((expireTime - now) / (24.0 * 60 * 60 * 1000));
// 提前3天、1天提醒过期当天提醒
if (daysRemaining == 3 || daysRemaining == 1 || daysRemaining == 0) {
try {
if (StringUtils.isNotEmpty(q.getCreateBy())) {
SysUser createUser = sysUserMapper.selectUserByUserName(q.getCreateBy());
if (createUser != null) {
String supplierName = q.getSupplierName() != null ? q.getSupplierName() : "供应商-" + q.getSupplierId();
String rfqTitle = q.getRfqTitle();
BigDecimal totalAmount = q.getTotalAmount();
notifyMessageService.sendQuotationExpireNotification(
q.getQuotationId(),
q.getQuoteNo(),
supplierName,
rfqTitle,
totalAmount,
daysRemaining,
createUser.getUserId()
);
remindCount++;
}
}
} catch (Exception e) {
log.error("报价到期提醒发送失败, quotationId={}", q.getQuotationId(), e);
}
}
}
log.info("=== 报价到期检查完成, 共发送 {} 条提醒 ===", remindCount);
}
}

View File

@@ -17,4 +17,58 @@
<select id="selectStatus" resultType="java.lang.String">
SELECT ${statusCol} FROM ${table} WHERE ${pk}=#{id}
</select>
<select id="selectBizCreateInfo" resultType="java.util.Map">
SELECT t.create_by AS createBy FROM ${table} t WHERE ${pk}=#{id}
</select>
<!-- 查询采购订单详情 -->
<select id="selectPurchaseOrderInfo" resultType="java.util.Map">
SELECT p.po_no AS bizNo, s.supplier_name AS partnerName, r.rfq_title AS bizTitle,
p.total_amount AS totalAmount, p.currency, p.create_time AS createTime, p.create_by AS createBy
FROM biz_purchase_order p
LEFT JOIN biz_supplier s ON p.supplier_id = s.supplier_id
LEFT JOIN biz_rfq r ON p.rfq_id = r.rfq_id
WHERE p.po_id = #{id}
</select>
<!-- 查询客户报价详情 -->
<select id="selectClientQuoteInfo" resultType="java.util.Map">
SELECT q.quote_no AS bizNo, q.client_name AS partnerName, q.rfq_title AS bizTitle,
q.total_amount AS totalAmount, q.currency, q.create_time AS createTime, q.create_by AS createBy
FROM biz_client_quote q
WHERE q.quote_id = #{id}
</select>
<!-- 查询供应商报价详情 -->
<select id="selectQuotationInfo" resultType="java.util.Map">
SELECT q.quote_no AS bizNo, s.supplier_name AS partnerName, r.rfq_title AS bizTitle,
q.total_amount AS totalAmount, q.currency, q.submit_time AS createTime, q.create_by AS createBy
FROM biz_quotation q
LEFT JOIN biz_supplier s ON q.supplier_id = s.supplier_id
LEFT JOIN biz_rfq r ON q.rfq_id = r.rfq_id
WHERE q.quotation_id = #{id}
</select>
<!-- 查询发货单详情 -->
<select id="selectDeliveryOrderInfo" resultType="java.util.Map">
SELECT d.do_no AS bizNo, s.supplier_name AS partnerName,
COALESCE(cl.client_name, cq.client_name) AS clientName,
d.total_amount AS totalAmount, d.currency, d.create_time AS createTime, d.create_by AS createBy
FROM biz_delivery_order d
LEFT JOIN biz_supplier s ON d.supplier_id = s.supplier_id
LEFT JOIN biz_client_quote cq ON d.client_quote_id = cq.quote_id
LEFT JOIN biz_client cl ON cq.client_id = cl.client_id
WHERE d.do_id = #{id}
</select>
<!-- 查询订单异议详情 -->
<select id="selectObjectionInfo" resultType="java.util.Map">
SELECT o.reason AS bizTitle, s.supplier_name AS partnerName, p.po_no AS bizNo,
o.create_time AS createTime, o.create_by AS createBy
FROM biz_order_objection o
LEFT JOIN biz_supplier s ON o.supplier_id = s.supplier_id
LEFT JOIN biz_purchase_order p ON o.po_id = p.po_id
WHERE o.objection_id = #{id}
</select>
</mapper>

View File

@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.system.mapper.bid.BizNotifyMessageMapper">
<resultMap id="NotifyMessageRM" type="com.ruoyi.system.domain.bid.BizNotifyMessage">
<id property="messageId" column="message_id"/>
<result property="tenantId" column="tenant_id"/>
<result property="userId" column="user_id"/>
<result property="noticeType" column="notice_type"/>
<result property="priority" column="priority"/>
<result property="title" column="title"/>
<result property="content" column="content"/>
<result property="bizType" column="biz_type"/>
<result property="bizId" column="biz_id"/>
<result property="bizUrl" column="biz_url"/>
<result property="isRead" column="is_read"/>
<result property="readTime" column="read_time"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
</resultMap>
<sql id="selectColumns">
message_id, tenant_id, user_id, notice_type, priority, title, content,
biz_type, biz_id, biz_url, is_read, read_time, create_by, create_time
</sql>
<select id="selectNotifyMessageList" parameterType="com.ruoyi.system.domain.bid.BizNotifyMessage" resultMap="NotifyMessageRM">
SELECT <include refid="selectColumns"/>
FROM biz_notify_message
<where>
<if test="userId != null"> AND user_id = #{userId} </if>
<if test="noticeType != null and noticeType != ''"> AND notice_type = #{noticeType} </if>
<if test="isRead != null and isRead != ''"> AND is_read = #{isRead} </if>
<if test="bizType != null and bizType != ''"> AND biz_type = #{bizType} </if>
<if test="priority != null"> AND priority = #{priority} </if>
<if test="params.beginTime != null and params.beginTime != ''"> AND create_time &gt;= #{params.beginTime} </if>
<if test="params.endTime != null and params.endTime != ''"> AND create_time &lt;= #{params.endTime} </if>
</where>
ORDER BY create_time DESC
</select>
<select id="selectNotifyMessageById" parameterType="Long" resultMap="NotifyMessageRM">
SELECT <include refid="selectColumns"/> FROM biz_notify_message WHERE message_id = #{messageId}
</select>
<insert id="insertNotifyMessage" parameterType="com.ruoyi.system.domain.bid.BizNotifyMessage" useGeneratedKeys="true" keyProperty="messageId">
INSERT INTO biz_notify_message
(tenant_id, user_id, notice_type, priority, title, content, biz_type, biz_id, biz_url, is_read, create_by, create_time)
VALUES
(#{tenantId}, #{userId}, #{noticeType}, #{priority}, #{title}, #{content}, #{bizType}, #{bizId}, #{bizUrl}, #{isRead}, #{createBy}, sysdate())
</insert>
<insert id="batchInsertNotifyMessage" parameterType="java.util.List">
INSERT INTO biz_notify_message
(tenant_id, user_id, notice_type, priority, title, content, biz_type, biz_id, biz_url, is_read, create_by, create_time)
VALUES
<foreach collection="list" item="m" separator=",">
(#{m.tenantId}, #{m.userId}, #{m.noticeType}, #{m.priority}, #{m.title}, #{m.content}, #{m.bizType}, #{m.bizId}, #{m.bizUrl}, '0', #{m.createBy}, sysdate())
</foreach>
</insert>
<update id="updateNotifyMessage" parameterType="com.ruoyi.system.domain.bid.BizNotifyMessage">
UPDATE biz_notify_message
<set>
<if test="isRead != null and isRead != ''"> is_read = #{isRead}, </if>
<if test="readTime != null"> read_time = #{readTime}, </if>
</set>
WHERE message_id = #{messageId}
</update>
<update id="markAsRead">
UPDATE biz_notify_message SET is_read = '1', read_time = sysdate()
WHERE message_id = #{messageId} AND user_id = #{userId} AND is_read = '0'
</update>
<update id="markAllAsRead">
UPDATE biz_notify_message SET is_read = '1', read_time = sysdate()
WHERE user_id = #{userId} AND is_read = '0'
</update>
<delete id="deleteNotifyMessageByIds">
DELETE FROM biz_notify_message WHERE message_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach>
</delete>
<select id="selectUnreadCount" resultType="int">
SELECT COUNT(*) FROM biz_notify_message WHERE user_id = #{userId} AND is_read = '0'
</select>
<select id="selectNotifyStats" resultType="java.util.Map">
SELECT
notice_type AS noticeType,
SUM(CASE WHEN is_read = '0' THEN 1 ELSE 0 END) AS unreadCount,
COUNT(*) AS totalCount
FROM biz_notify_message
WHERE user_id = #{userId}
GROUP BY notice_type
</select>
<select id="selectTopUnread" resultMap="NotifyMessageRM">
SELECT <include refid="selectColumns"/>
FROM biz_notify_message
WHERE user_id = #{userId} AND is_read = '0'
ORDER BY priority DESC, create_time DESC
LIMIT #{limit}
</select>
<select id="selectTopNotify" resultMap="NotifyMessageRM">
SELECT <include refid="selectColumns"/>
FROM biz_notify_message
WHERE user_id = #{userId}
ORDER BY priority DESC, create_time DESC
LIMIT #{limit}
</select>
</mapper>

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.system.mapper.bid.BizNotifyRuleMapper">
<resultMap id="NotifyRuleRM" type="com.ruoyi.system.domain.bid.BizNotifyRule">
<id property="ruleId" column="rule_id"/>
<result property="tenantId" column="tenant_id"/>
<result property="ruleName" column="rule_name"/>
<result property="noticeType" column="notice_type"/>
<result property="bizType" column="biz_type"/>
<result property="triggerCondition" column="trigger_condition"/>
<result property="advanceDays" column="advance_days"/>
<result property="enabled" column="enabled"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
</resultMap>
<select id="selectNotifyRuleList" parameterType="com.ruoyi.system.domain.bid.BizNotifyRule" resultMap="NotifyRuleRM">
SELECT rule_id, tenant_id, rule_name, notice_type, biz_type, trigger_condition, advance_days, enabled, create_by, create_time
FROM biz_notify_rule
<where>
<if test="noticeType != null and noticeType != ''"> AND notice_type = #{noticeType} </if>
<if test="bizType != null and bizType != ''"> AND biz_type = #{bizType} </if>
<if test="enabled != null and enabled != ''"> AND enabled = #{enabled} </if>
</where>
ORDER BY rule_id
</select>
<select id="selectNotifyRuleById" parameterType="Long" resultMap="NotifyRuleRM">
SELECT rule_id, tenant_id, rule_name, notice_type, biz_type, trigger_condition, advance_days, enabled, create_by, create_time
FROM biz_notify_rule WHERE rule_id = #{ruleId}
</select>
<insert id="insertNotifyRule" parameterType="com.ruoyi.system.domain.bid.BizNotifyRule" useGeneratedKeys="true" keyProperty="ruleId">
INSERT INTO biz_notify_rule (tenant_id, rule_name, notice_type, biz_type, trigger_condition, advance_days, enabled, create_by, create_time)
VALUES (#{tenantId}, #{ruleName}, #{noticeType}, #{bizType}, #{triggerCondition}, #{advanceDays}, #{enabled}, #{createBy}, sysdate())
</insert>
<update id="updateNotifyRule" parameterType="com.ruoyi.system.domain.bid.BizNotifyRule">
UPDATE biz_notify_rule
<set>
<if test="ruleName != null and ruleName != ''"> rule_name = #{ruleName}, </if>
<if test="noticeType != null and noticeType != ''"> notice_type = #{noticeType}, </if>
<if test="bizType != null"> biz_type = #{bizType}, </if>
<if test="triggerCondition != null"> trigger_condition = #{triggerCondition}, </if>
<if test="advanceDays != null"> advance_days = #{advanceDays}, </if>
<if test="enabled != null and enabled != ''"> enabled = #{enabled}, </if>
</set>
WHERE rule_id = #{ruleId}
</update>
<delete id="deleteNotifyRuleByIds">
DELETE FROM biz_notify_rule WHERE rule_id IN
<foreach collection="array" item="id" open="(" separator="," close=")"> #{id} </foreach>
</delete>
<select id="selectEnabledRules" resultMap="NotifyRuleRM">
SELECT rule_id, tenant_id, rule_name, notice_type, biz_type, trigger_condition, advance_days, enabled, create_by, create_time
FROM biz_notify_rule WHERE enabled = '1'
</select>
</mapper>

View File

@@ -0,0 +1,71 @@
import request from '@/utils/request'
// 查询消息通知列表
export function listNotify(data) {
return request({ url: '/bid/notify/list', method: 'get', params: data })
}
// 查询未读数量
export function getUnreadCount() {
return request({ url: '/bid/notify/unreadCount', method: 'get' })
}
// 查询最新未读消息(仅未读)
export function getTopUnread(limit) {
return request({ url: '/bid/notify/topUnread', method: 'get', params: { limit } })
}
// 查询最近通知(含已读和未读,用于铃铛面板)
export function getTopNotify(limit) {
return request({ url: '/bid/notify/topNotify', method: 'get', params: { limit } })
}
// 查询通知统计
export function getNotifyStats() {
return request({ url: '/bid/notify/stats', method: 'get' })
}
// 查询消息详情
export function getNotify(messageId) {
return request({ url: '/bid/notify/' + messageId, method: 'get' })
}
// 标记单条已读
export function markRead(messageId) {
return request({ url: '/bid/notify/read/' + messageId, method: 'put' })
}
// 全部已读
export function markAllRead() {
return request({ url: '/bid/notify/readAll', method: 'put' })
}
// 删除消息
export function delNotify(messageIds) {
return request({ url: '/bid/notify/' + messageIds, method: 'delete' })
}
// 通知规则列表
export function listNotifyRule(data) {
return request({ url: '/bid/notify/rule/list', method: 'get', params: data })
}
// 通知规则详情
export function getNotifyRule(ruleId) {
return request({ url: '/bid/notify/rule/' + ruleId, method: 'get' })
}
// 新增规则
export function addNotifyRule(data) {
return request({ url: '/bid/notify/rule', method: 'post', data })
}
// 修改规则
export function updateNotifyRule(data) {
return request({ url: '/bid/notify/rule', method: 'put', data })
}
// 删除规则
export function delNotifyRule(ruleIds) {
return request({ url: '/bid/notify/rule/' + ruleIds, method: 'delete' })
}

View File

@@ -1,110 +1,191 @@
<template>
<div>
<el-popover ref="noticePopover" placement="bottom-end" width="320" trigger="manual" :value="noticeVisible" popper-class="notice-popover">
<div class="notice-header">
<span class="notice-title">通知公告</span>
<span class="notice-mark-all" @click="markAllRead">全部已读</span>
<el-popover ref="notifyPopover" placement="bottom-end" width="360" trigger="manual" :value="notifyVisible" popper-class="notify-popover">
<!-- 顶部标题栏 -->
<div class="notify-header">
<div class="notify-tabs">
<span class="notify-tab" :class="{ active: activeTab === 'all' }" @click="activeTab = 'all'">
全部 <span class="tab-count" v-if="unreadCount > 0">{{ unreadCount }}</span>
</span>
<span class="notify-tab" :class="{ active: activeTab === 'unread' }" @click="activeTab = 'unread'">
未读
</span>
</div>
<div class="notify-actions">
<span class="notify-action" @click="markAllRead" v-if="unreadCount > 0">全部已读</span>
<span class="notify-action" @click="goToNotifyCenter">查看全部</span>
</div>
</div>
<div v-if="noticeLoading" class="notice-loading"><i class="el-icon-loading"></i> 加载中...</div>
<div v-else-if="noticeList.length === 0" class="notice-empty"><i class="el-icon-inbox"></i><br>暂无公告</div>
<div v-else>
<div v-for="item in noticeList" :key="item.noticeId" class="notice-item" :class="{ 'is-read': item.isRead }" @click="previewNotice(item)">
<el-tag size="mini" :type="item.noticeType === '1' ? 'warning' : 'success'" class="notice-tag">
{{ item.noticeType === '1' ? '通知' : '公告' }}
</el-tag>
<span class="notice-item-title">{{ item.noticeTitle }}</span>
<span class="notice-item-date">{{ item.createTime }}</span>
<!-- 通知列表 -->
<div v-loading="loading" class="notify-body">
<div v-if="filteredList.length === 0" class="notify-empty">
<i class="el-icon-bell" style="font-size:32px;color:#dcdfe6"></i>
<p>暂无通知</p>
</div>
<div v-for="item in filteredList" :key="item.messageId"
class="notify-item"
:class="{ 'is-read': item.isRead === '1', 'is-urgent': item.priority === 2 }"
@click="handleItemClick(item)">
<div class="item-icon" :style="{ background: typeColor(item.noticeType) }">
<i :class="typeIcon(item.noticeType)"></i>
</div>
<div class="item-content">
<div class="item-title-row">
<span class="item-title">{{ item.title }}</span>
<span v-if="item.isRead === '0'" class="unread-dot"></span>
</div>
<div class="item-desc">{{ item.content }}</div>
<div class="item-meta">
<el-tag size="mini" :type="typeTagType(item.noticeType)" effect="plain">{{ typeLabel(item.noticeType) }}</el-tag>
<span class="item-time">{{ formatTime(item.createTime) }}</span>
</div>
</div>
</div>
</div>
</el-popover>
<div v-popover:noticePopover class="right-menu-item hover-effect notice-trigger" @mouseenter="onNoticeEnter" @mouseleave="onNoticeLeave">
<div v-popover:notifyPopover class="right-menu-item hover-effect notify-trigger" @mouseenter="onEnter" @mouseleave="onLeave">
<svg-icon icon-class="bell" />
<span v-if="unreadCount > 0" class="notice-badge">{{ unreadCount }}</span>
<span v-if="unreadCount > 0" class="notify-badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</div>
<notice-detail-view ref="noticeViewRef" />
</div>
</template>
<script>
import NoticeDetailView from './DetailView'
import { listNoticeTop, markNoticeRead, markNoticeReadAll } from '@/api/system/notice'
import { getTopNotify, getUnreadCount, markRead, markAllRead } from '@/api/bid/notify'
export default {
name: 'HeaderNotice',
components: { NoticeDetailView },
data() {
return {
noticeList: [], // 通知列表
unreadCount: 0, // 未读数量
noticeLoading: false, // 加载状态
noticeVisible: false, // 弹出层显示状态
noticeLeaveTimer: null // 鼠标离开计时器
notifyList: [],
unreadCount: 0,
loading: false,
notifyVisible: false,
activeTab: 'all',
leaveTimer: null,
pollTimer: null
}
},
computed: {
filteredList() {
if (this.activeTab === 'unread') {
return this.notifyList.filter(x => x.isRead === '0')
}
return this.notifyList
}
},
mounted() {
this.loadNoticeTop()
this.loadData()
// 每 60 秒轮询一次未读数
this.pollTimer = setInterval(() => {
this.loadUnreadCount()
}, 60000)
},
beforeDestroy() {
if (this.pollTimer) clearInterval(this.pollTimer)
},
methods: {
// 鼠标移入铃铛区域
onNoticeEnter() {
clearTimeout(this.noticeLeaveTimer)
this.noticeVisible = true
onEnter() {
clearTimeout(this.leaveTimer)
this.notifyVisible = true
this.loadData()
this.$nextTick(() => {
const popper = this.$refs.noticePopover.$refs.popper
if (popper && !popper._noticeBound) {
popper._noticeBound = true
popper.addEventListener('mouseenter', () => clearTimeout(this.noticeLeaveTimer))
const popper = this.$refs.notifyPopover.$refs.popper
if (popper && !popper._notifyBound) {
popper._notifyBound = true
popper.addEventListener('mouseenter', () => clearTimeout(this.leaveTimer))
popper.addEventListener('mouseleave', () => {
this.noticeLeaveTimer = setTimeout(() => { this.noticeVisible = false }, 100)
this.leaveTimer = setTimeout(() => { this.notifyVisible = false }, 100)
})
}
})
},
// 鼠标离开铃铛区域
onNoticeLeave() {
this.noticeLeaveTimer = setTimeout(() => { this.noticeVisible = false }, 150)
onLeave() {
this.leaveTimer = setTimeout(() => { this.notifyVisible = false }, 150)
},
// 加载顶部公告列表
loadNoticeTop() {
this.noticeLoading = true
listNoticeTop().then(res => {
this.noticeList = res.data || []
this.unreadCount = res.unreadCount !== undefined ? res.unreadCount : this.noticeList.filter(n => !n.isRead).length
loadData() {
this.loading = true
Promise.all([
getTopNotify(10),
getUnreadCount()
]).then(([listRes, countRes]) => {
this.notifyList = listRes.data || []
this.unreadCount = countRes.data || 0
}).finally(() => {
this.noticeLoading = false
this.loading = false
})
},
// 预览公告详情
previewNotice(item) {
if (!item.isRead) {
markNoticeRead(item.noticeId).catch(() => {})
item.isRead = true
const idx = this.noticeList.indexOf(item)
if (idx !== -1) this.$set(this.noticeList, idx, { ...item, isRead: true })
this.unreadCount = Math.max(0, this.unreadCount - 1)
}
this.$refs.noticeViewRef.open(item.noticeId)
loadUnreadCount() {
getUnreadCount().then(res => {
const newCount = res.data || 0
if (newCount !== this.unreadCount) {
this.unreadCount = newCount
// 未读数变化时重新加载列表
if (this.notifyVisible) this.loadData()
}
}).catch(() => {})
},
handleItemClick(item) {
if (item.isRead === '0') {
markRead(item.messageId).then(() => {
item.isRead = '1'
this.unreadCount = Math.max(0, this.unreadCount - 1)
}).catch(() => {})
}
// 跳转到消息通知中心
this.notifyVisible = false
this.$router.push('/bizconfig/notify').catch(() => {})
},
// 全部已读
markAllRead() {
const ids = this.noticeList.map(n => n.noticeId).join(',')
if (!ids) return
markNoticeReadAll(ids).catch(() => {})
this.noticeList = this.noticeList.map(n => ({ ...n, isRead: true }))
this.unreadCount = 0
markAllRead().then(() => {
this.notifyList.forEach(x => { x.isRead = '1' })
this.unreadCount = 0
this.$message({ message: '已全部标记为已读', type: 'success', duration: 1500 })
})
},
goToNotifyCenter() {
this.notifyVisible = false
this.$router.push('/bizconfig/notify').catch(() => {})
},
typeLabel(t) {
const map = { approval: '审批', quotation_expire: '到期', rfq_deadline: 'RFQ', system: '公告', exception: '异常' }
return map[t] || t
},
typeTagType(t) {
const map = { approval: 'warning', quotation_expire: 'danger', rfq_deadline: 'danger', system: 'success', exception: 'danger' }
return map[t] || 'info'
},
typeColor(t) {
const map = { approval: '#E6A23C', quotation_expire: '#F56C6C', rfq_deadline: '#F56C6C', system: '#67C23A', exception: '#F56C6C' }
return map[t] || '#409EFF'
},
typeIcon(t) {
const map = { approval: 'el-icon-s-check', quotation_expire: 'el-icon-time', rfq_deadline: 'el-icon-alarm-clock', system: 'el-icon-bell', exception: 'el-icon-warning' }
return map[t] || 'el-icon-info'
},
formatTime(t) {
if (!t) return ''
const date = new Date(t)
const now = new Date()
const diff = (now - date) / 1000
if (diff < 60) return '刚刚'
if (diff < 3600) return Math.floor(diff / 60) + '分钟前'
if (diff < 86400) return Math.floor(diff / 3600) + '小时前'
if (diff < 604800) return Math.floor(diff / 86400) + '天前'
return this.parseTime(t, '{y}-{m}-{d} {h}:{i}')
}
}
}
</script>
<style lang="scss" scoped>
.notice-trigger {
.notify-trigger {
position: relative;
transform: translateX(-6px);
.svg-icon { width: 1.2em; height: 1.2em; vertical-align: -0.2em; }
.notice-badge {
.notify-badge {
position: absolute;
top: 7px;
right: -3px;
@@ -121,61 +202,56 @@ export default {
pointer-events: none;
}
}
.notice-popover {
padding: 0 !important;
}
.notice-popover .notice-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #f7f9fb;
border-bottom: 1px solid #eee;
font-size: 13px;
font-weight: 600;
color: #333;
}
.notice-popover .notice-mark-all {
font-size: 12px;
color: #e4393c;
font-weight: normal;
cursor: pointer;
}
.notice-popover .notice-mark-all:hover { color: #2b7cc1; }
.notice-popover .notice-loading,
.notice-popover .notice-empty {
padding: 24px;
text-align: center;
color: #bbb;
font-size: 12px;
line-height: 1.8;
}
.notice-popover .notice-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #ffffff;
cursor: pointer;
transition: background 0.15s;
}
.notice-popover .notice-item:last-child { border-bottom: none; }
.notice-popover .notice-item:hover { background: #f7f9fb; }
.notice-popover .notice-item.is-read .notice-tag,
.notice-popover .notice-item.is-read .notice-item-title,
.notice-popover .notice-item.is-read .notice-item-date { opacity: 0.45; filter: grayscale(1); color: #999; }
.notice-popover .notice-tag { flex-shrink: 0; }
.notice-popover .notice-item-title {
flex: 1;
font-size: 12px;
color: #333;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.notice-popover .notice-item-date {
flex-shrink: 0;
font-size: 11px;
color: #bbb;
}
</style>
<style>
.notify-popover { padding: 0 !important; }
.notify-popover .notify-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; background: #f7f9fb; border-bottom: 1px solid #eee;
}
.notify-popover .notify-tabs { display: flex; gap: 12px; }
.notify-popover .notify-tab {
font-size: 13px; color: #909399; cursor: pointer; padding: 2px 0;
display: flex; align-items: center; gap: 4px;
}
.notify-popover .notify-tab.active { color: #409EFF; font-weight: 600; border-bottom: 2px solid #409EFF; }
.notify-popover .tab-count {
background: #f56c6c; color: #fff; border-radius: 10px;
font-size: 10px; padding: 0 5px; height: 16px; line-height: 16px; min-width: 16px; text-align: center;
}
.notify-popover .notify-actions { display: flex; gap: 10px; }
.notify-popover .notify-action { font-size: 12px; color: #409EFF; cursor: pointer; }
.notify-popover .notify-action:hover { color: #2b7cc1; }
.notify-popover .notify-body { max-height: 400px; overflow-y: auto; }
.notify-popover .notify-empty { padding: 32px; text-align: center; color: #bbb; font-size: 12px; }
.notify-popover .notify-empty i { display: block; margin-bottom: 8px; }
.notify-popover .notify-item {
display: flex; gap: 10px; padding: 12px 14px;
border-bottom: 1px solid #f2f3f5; cursor: pointer; transition: background 0.15s;
}
.notify-popover .notify-item:hover { background: #f7f9fb; }
.notify-popover .notify-item.is-read { opacity: 0.55; }
.notify-popover .notify-item.is-urgent { border-left: 3px solid #f56c6c; }
.notify-popover .item-icon {
width: 32px; height: 32px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 16px; flex-shrink: 0;
}
.notify-popover .item-content { flex: 1; min-width: 0; }
.notify-popover .item-title-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.notify-popover .item-title {
font-size: 13px; font-weight: 600; color: #303133;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;
}
.notify-popover .unread-dot { width: 8px; height: 8px; border-radius: 50%; background: #f56c6c; flex-shrink: 0; }
.notify-popover .item-desc {
font-size: 12px; color: #606266; line-height: 1.5;
overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.notify-popover .item-meta { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.notify-popover .item-time { font-size: 11px; color: #c0c4cc; }
</style>

View File

@@ -18,9 +18,7 @@
<size-select id="size-select" class="right-menu-item hover-effect" />
</el-tooltip>
<el-tooltip content="消息通知" effect="dark" placement="bottom">
<header-notice id="header-notice" class="right-menu-item hover-effect" />
</el-tooltip>
<header-notice id="header-notice" class="right-menu-item hover-effect" />
</template>

View File

@@ -307,6 +307,21 @@ export const dynamicRoutes = [
}]
},
{
path: '/bizconfig/notify',
component: Layout,
hidden: true,
permissions: ['bid:notify:list'],
children: [
{
path: '',
component: () => import('@/views/bid/notify/index'),
name: 'NotifyCenter',
meta: { title: '消息通知中心', activeMenu: '/bizconfig/notify' }
}
]
},
{
path: '/system/user-auth',
component: Layout,

View File

@@ -0,0 +1,270 @@
<template>
<div class="notify-page">
<!-- 顶部统计卡片 -->
<el-row :gutter="12" class="stat-row">
<el-col :xs="12" :sm="6" v-for="card in statCards" :key="card.label">
<div class="stat-card" :style="{ borderTop: '3px solid ' + card.color }">
<div class="stat-label">{{ card.label }}</div>
<div class="stat-value" :style="{ color: card.color }">{{ card.value }}</div>
<i :class="card.icon" class="stat-icon" :style="{ color: card.color }"></i>
</div>
</el-col>
</el-row>
<!-- 筛选栏 -->
<div class="search-bar">
<el-select v-model="queryParams.noticeType" placeholder="通知类型" clearable size="small" style="width:130px" @change="handleQuery">
<el-option label="审批结果" value="approval" />
<el-option label="报价到期" value="quotation_expire" />
<el-option label="RFQ截止" value="rfq_deadline" />
<el-option label="系统公告" value="system" />
<el-option label="异常提醒" value="exception" />
</el-select>
<el-select v-model="queryParams.isRead" placeholder="阅读状态" clearable size="small" style="width:110px" @change="handleQuery">
<el-option label="未读" value="0" />
<el-option label="已读" value="1" />
</el-select>
<el-select v-model="queryParams.priority" placeholder="优先级" clearable size="small" style="width:110px" @change="handleQuery">
<el-option label="普通" :value="0" />
<el-option label="重要" :value="1" />
<el-option label="紧急" :value="2" />
</el-select>
<el-button type="primary" size="small" icon="el-icon-search" @click="handleQuery">搜索</el-button>
<el-button size="small" icon="el-icon-refresh" @click="resetQuery">重置</el-button>
<div style="flex:1"></div>
<el-button size="small" type="success" icon="el-icon-check" @click="handleMarkAllRead" :disabled="unreadCount === 0">全部已读</el-button>
<el-button size="small" type="danger" icon="el-icon-delete" @click="handleBatchDelete" :disabled="selectedIds.length === 0">批量删除</el-button>
</div>
<!-- 消息列表 -->
<div class="notify-list" v-loading="loading">
<div v-if="list.length === 0 && !loading" class="empty-state">
<i class="el-icon-bell" style="font-size:48px;color:#dcdfe6"></i>
<p>暂无通知消息</p>
</div>
<div v-for="item in list" :key="item.messageId"
class="notify-item"
:class="{ 'is-read': item.isRead === '1', 'is-urgent': item.priority === 2 }"
@click="handleItemClick(item)">
<el-checkbox :value="selectedIds.includes(item.messageId)" @change="toggleSelect(item.messageId)" class="item-check" @click.native.stop />
<div class="item-dot" :class="{ unread: item.isRead === '0' }"></div>
<div class="item-body">
<div class="item-header">
<el-tag size="mini" :type="typeTagType(item.noticeType)" effect="plain">{{ typeLabel(item.noticeType) }}</el-tag>
<el-tag v-if="item.priority > 0" size="mini" :type="item.priority === 2 ? 'danger' : 'warning'" effect="dark" style="margin-left:4px">{{ item.priority === 2 ? '紧急' : '重要' }}</el-tag>
<span class="item-title">{{ item.title }}</span>
<span class="item-time">{{ formatTime(item.createTime) }}</span>
</div>
<div class="item-content">{{ item.content }}</div>
<div class="item-footer" v-if="item.bizUrl">
<el-button type="text" size="mini" @click.stop="goToBiz(item)">
<i class="el-icon-link"></i> 查看详情
</el-button>
</div>
</div>
<div class="item-actions" @click.stop>
<el-button v-if="item.isRead === '0'" type="text" size="mini" @click="markOneRead(item)">
<i class="el-icon-check"></i> 已读
</el-button>
<el-button type="text" size="mini" style="color:#F56C6C" @click="handleDelete(item)">
<i class="el-icon-delete"></i>
</el-button>
</div>
</div>
</div>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<!-- 规则配置对话框 -->
<el-dialog title="通知规则配置" :visible.sync="ruleDialogVisible" width="700px" append-to-body>
<el-table :data="ruleList" border size="small" v-loading="ruleLoading">
<el-table-column label="规则名称" prop="ruleName" min-width="120" />
<el-table-column label="通知类型" width="100" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="typeTagType(scope.row.noticeType)">{{ typeLabel(scope.row.noticeType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="业务类型" prop="bizType" width="110" align="center" />
<el-table-column label="提前天数" prop="advanceDays" width="80" align="center" />
<el-table-column label="状态" width="80" align="center">
<template slot-scope="scope">
<el-switch :value="scope.row.enabled === '1'" @change="toggleRule(scope.row)" active-text="启用" inactive-text="停用" />
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
<script>
import { listNotify, getUnreadCount, getNotifyStats, markRead, markAllRead, delNotify, listNotifyRule, updateNotifyRule } from '@/api/bid/notify'
export default {
name: 'BizNotifyIndex',
data() {
return {
loading: false,
list: [],
total: 0,
unreadCount: 0,
selectedIds: [],
stats: [],
ruleDialogVisible: false,
ruleLoading: false,
ruleList: [],
queryParams: {
pageNum: 1,
pageSize: 15,
noticeType: undefined,
isRead: undefined,
priority: undefined
}
}
},
computed: {
statCards() {
const s = this.stats
const findStat = (type) => (s.find(x => x.noticeType === type) || {})
return [
{ label: '未读通知', value: this.unreadCount, color: '#F56C6C', icon: 'el-icon-bell' },
{ label: '审批通知', value: findStat('approval').unreadCount || 0, color: '#E6A23C', icon: 'el-icon-s-check' },
{ label: '到期提醒', value: (findStat('quotation_expire').unreadCount || 0) + (findStat('rfq_deadline').unreadCount || 0), color: '#409EFF', icon: 'el-icon-time' },
{ label: '总通知数', value: s.reduce((a, b) => a + (b.totalCount || 0), 0), color: '#67C23A', icon: 'el-icon-document' }
]
}
},
mounted() {
this.getList()
this.loadStats()
},
methods: {
getList() {
this.loading = true
listNotify(this.queryParams).then(res => {
this.list = res.rows || []
this.total = res.total || 0
this.loading = false
}).catch(() => { this.loading = false })
},
loadStats() {
getUnreadCount().then(res => { this.unreadCount = res.data || 0 })
getNotifyStats().then(res => { this.stats = res.data || [] })
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.queryParams = { pageNum: 1, pageSize: 15, noticeType: undefined, isRead: undefined, priority: undefined }
this.handleQuery()
},
handleItemClick(item) {
if (item.isRead === '0') {
this.markOneRead(item)
}
},
markOneRead(item) {
markRead(item.messageId).then(() => {
item.isRead = '1'
this.unreadCount = Math.max(0, this.unreadCount - 1)
this.loadStats()
})
},
handleMarkAllRead() {
this.$modal.confirm('确定将所有通知标记为已读吗?').then(() => {
return markAllRead()
}).then(() => {
this.list.forEach(x => { x.isRead = '1' })
this.unreadCount = 0
this.loadStats()
this.$message.success('已全部标记为已读')
}).catch(() => {})
},
handleDelete(item) {
this.$modal.confirm('确定删除该通知吗?').then(() => {
return delNotify(item.messageId)
}).then(() => {
this.getList()
this.loadStats()
}).catch(() => {})
},
handleBatchDelete() {
if (this.selectedIds.length === 0) return
this.$modal.confirm('确定删除选中的通知吗?').then(() => {
return delNotify(this.selectedIds.join(','))
}).then(() => {
this.selectedIds = []
this.getList()
this.loadStats()
}).catch(() => {})
},
toggleSelect(id) {
const idx = this.selectedIds.indexOf(id)
if (idx === -1) this.selectedIds.push(id)
else this.selectedIds.splice(idx, 1)
},
goToBiz(item) {
if (item.isRead === '0') this.markOneRead(item)
if (item.bizUrl) this.$router.push(item.bizUrl)
},
typeLabel(t) {
const map = { approval: '审批结果', quotation_expire: '报价到期', rfq_deadline: 'RFQ截止', system: '系统公告', exception: '异常提醒' }
return map[t] || t
},
typeTagType(t) {
const map = { approval: 'warning', quotation_expire: 'danger', rfq_deadline: 'danger', system: 'success', exception: 'danger' }
return map[t] || 'info'
},
formatTime(t) {
if (!t) return ''
return this.parseTime(t, '{y}-{m}-{d} {h}:{i}')
}
}
}
</script>
<style scoped>
.notify-page { padding: 16px; background: #f5f7fa; min-height: calc(100vh - 84px); }
.stat-row { margin-bottom: 16px; }
.stat-card {
background: #fff; border-radius: 4px; padding: 16px 18px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06); position: relative; overflow: hidden; margin-bottom: 12px;
}
.stat-label { font-size: 12px; color: #909399; margin-bottom: 8px; }
.stat-value { font-size: 28px; font-weight: 700; }
.stat-icon { position: absolute; right: 18px; top: 50%; transform: translateY(-50%); font-size: 40px; opacity: 0.12; }
.search-bar {
background: #fff; padding: 12px 16px; border-radius: 4px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06); margin-bottom: 12px;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}
.notify-list { background: #fff; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.empty-state { padding: 60px 0; text-align: center; color: #c0c4cc; }
.notify-item {
display: flex; align-items: flex-start; padding: 14px 16px;
border-bottom: 1px solid #f2f3f5; cursor: pointer; transition: background 0.15s;
}
.notify-item:hover { background: #f7f9fb; }
.notify-item.is-read { opacity: 0.65; }
.notify-item.is-urgent { border-left: 3px solid #f56c6c; }
.item-check { margin-right: 8px; margin-top: 4px; }
.item-dot {
width: 8px; height: 8px; border-radius: 50%; background: #dcdfe6;
margin-right: 10px; margin-top: 6px; flex-shrink: 0;
}
.item-dot.unread { background: #f56c6c; }
.item-body { flex: 1; min-width: 0; }
.item-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.item-title { font-size: 13px; font-weight: 600; color: #303133; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-time { font-size: 11px; color: #c0c4cc; flex-shrink: 0; }
.item-content { font-size: 12px; color: #606266; line-height: 1.6; margin-bottom: 4px; }
.item-footer { margin-top: 2px; }
.item-actions { flex-shrink: 0; margin-left: 12px; display: flex; flex-direction: column; gap: 4px; }
</style>

5
sql/_q2.sql Normal file
View File

@@ -0,0 +1,5 @@
SELECT menu_id, menu_name, parent_id, path FROM sys_menu WHERE parent_id=2000 ORDER BY menu_id;
SELECT '---bid_sub2---' AS '';
SELECT m.menu_id, m.menu_name, m.parent_id, m.path, m.component FROM sys_menu m WHERE m.parent_id IN (SELECT menu_id FROM sys_menu WHERE parent_id=2000);
SELECT '---bizconfig---' AS '';
SELECT menu_id, menu_name, parent_id, path, component FROM sys_menu WHERE parent_id=2130;

1
sql/_q3.sql Normal file
View File

@@ -0,0 +1 @@
SELECT menu_id, menu_name, parent_id, path, component FROM sys_menu WHERE parent_id=2060;

3
sql/_query.sql Normal file
View File

@@ -0,0 +1,3 @@
SELECT menu_id, menu_name, parent_id, order_num, path FROM sys_menu WHERE menu_name LIKE '%approval%' OR menu_name LIKE '%待我%' OR path LIKE '%approval%' OR path LIKE '%待我%';
SELECT '---' AS 'all_menus';
SELECT menu_id, menu_name, parent_id, order_num, path FROM sys_menu ORDER BY parent_id, order_num;

72
sql/bid_notify.sql Normal file
View File

@@ -0,0 +1,72 @@
-- ════════════════════════════════════════════════════════════════════
-- 消息通知中心 - 数据库表结构
-- 功能:统一管理站内通知,支持审批结果、报价到期、任务分配等场景
-- 执行方式: mysql --default-character-set=utf8mb4 -u root -p < bid_notify.sql
-- ════════════════════════════════════════════════════════════════════
SET NAMES utf8mb4;
-- 1. 消息通知表(存储所有通知消息)
DROP TABLE IF EXISTS biz_notify_message;
CREATE TABLE biz_notify_message (
message_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID',
tenant_id BIGINT DEFAULT 0 COMMENT '租户ID',
user_id BIGINT NOT NULL COMMENT '接收人用户ID',
notice_type VARCHAR(32) NOT NULL COMMENT '通知类型(approval-审批结果/quotation_expire-报价到期/rfq_deadline-RFQ截止/task-任务分配/system-系统公告/exception-异常提醒)',
priority TINYINT DEFAULT 0 COMMENT '优先级(0普通 1重要 2紧急)',
title VARCHAR(200) NOT NULL COMMENT '消息标题',
content TEXT COMMENT '消息内容',
biz_type VARCHAR(32) COMMENT '关联业务类型(PURCHASE_ORDER/CLIENT_QUOTE/QUOTATION/DELIVERY_ORDER/ORDER_OBJECTION)',
biz_id BIGINT COMMENT '关联业务ID',
biz_url VARCHAR(255) COMMENT '业务跳转URL',
is_read CHAR(1) DEFAULT '0' COMMENT '是否已读(0未读 1已读)',
read_time DATETIME COMMENT '阅读时间',
create_by VARCHAR(64) DEFAULT '' COMMENT '创建者',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT '' COMMENT '更新者',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
KEY idx_user_read (user_id, is_read),
KEY idx_user_type (user_id, notice_type),
KEY idx_tenant (tenant_id),
KEY idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息通知表';
-- 2. 通知规则配置表(定义各类通知的触发规则)
DROP TABLE IF EXISTS biz_notify_rule;
CREATE TABLE biz_notify_rule (
rule_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '规则ID',
tenant_id BIGINT DEFAULT 0 COMMENT '租户ID',
rule_name VARCHAR(100) NOT NULL COMMENT '规则名称',
notice_type VARCHAR(32) NOT NULL COMMENT '通知类型',
biz_type VARCHAR(32) COMMENT '业务类型',
trigger_condition VARCHAR(500) COMMENT '触发条件(JSON格式)',
advance_days INT DEFAULT 0 COMMENT '提前提醒天数(用于到期类提醒)',
enabled CHAR(1) DEFAULT '0' COMMENT '是否启用(0停用 1启用)',
create_by VARCHAR(64) DEFAULT '' COMMENT '创建者',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT '' COMMENT '更新者',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
KEY idx_tenant_type (tenant_id, notice_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知规则配置表';
-- 3. 初始化默认通知规则
INSERT INTO biz_notify_rule (rule_name, notice_type, biz_type, trigger_condition, advance_days, enabled, create_by) VALUES
('审批结果通知', 'approval', NULL, '{"event":"approve,reject"}', 0, '1', 'admin'),
('报价到期提醒', 'quotation_expire', 'QUOTATION', '{"field":"submit_time","unit":"day"}', 3, '1', 'admin'),
('报价到期紧急提醒', 'quotation_expire', 'QUOTATION', '{"field":"submit_time","unit":"day"}', 1, '1', 'admin'),
('RFQ截止提醒', 'rfq_deadline', NULL, '{"field":"deadline","unit":"day"}', 2, '1', 'admin'),
('系统公告', 'system', NULL, '{}', 0, '1', 'admin');
-- 4. 菜单权限初始化
-- 消息通知中心菜单(挂在"系统配置"目录 menu_id=2130 path=bizconfig 下,与"待我审批"同级)
DELETE FROM sys_menu WHERE menu_id IN (2170, 2171, 2172, 2173, 2174);
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) VALUES
(2170, '消息通知中心', 2130, 100, 'notify', 'bid/notify/index', 1, 0, 'C', '0', '0', 'bid:notify:list', 'message', 'admin', sysdate(), '消息通知中心'),
(2171, '通知查询', 2170, 1, '', '', 1, 0, 'F', '0', '0', 'bid:notify:query', '#', 'admin', sysdate(), ''),
(2172, '通知删除', 2170, 2, '', '', 1, 0, 'F', '0', '0', 'bid:notify:remove', '#', 'admin', sysdate(), ''),
(2173, '标记已读', 2170, 3, '', '', 1, 0, 'F', '0', '0', 'bid:notify:read', '#', 'admin', sysdate(), ''),
(2174, '通知规则配置', 2170, 4, '', '', 1, 0, 'F', '0', '0', 'bid:notify:rule', '#', 'admin', sysdate(), '');
-- 为 admin 角色授权
DELETE FROM sys_role_menu WHERE role_id = 1 AND menu_id IN (2170, 2171, 2172, 2173, 2174);
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
(1, 2170), (1, 2171), (1, 2172), (1, 2173), (1, 2174);

20
sql/bid_notify_fix.sql Normal file
View File

@@ -0,0 +1,20 @@
-- ════════════════════════════════════════════════════════════════════
-- 消息通知中心 - Bug 修复脚本
-- 修复内容:
-- 1. 注册 Quartz 定时任务(报价到期检查)
-- 2. 将现有待审批的 CLIENT_QUOTE(quote_id=6) 状态重置为 draft
-- ════════════════════════════════════════════════════════════════════
SET NAMES utf8mb4;
-- 1. 注册 Quartz 定时任务报价到期检查每天上午9点执行
-- 如果已通过 UI 注册则跳过
DELETE FROM sys_job WHERE job_name = '报价到期检查' AND job_group = 'DEFAULT';
INSERT INTO sys_job (job_id, job_name, job_group, invoke_target, cron_expression,
misfire_policy, concurrent, status, create_by, create_time, remark)
VALUES (100, '报价到期检查', 'DEFAULT',
'quotationExpireTask.checkQuotationExpire()',
'0 0 9 * * ?',
'3', '1', '0', 'admin', sysdate(), '报价到期提醒,每天检查即将到期和已过期的报价单');
-- 2. 将 quote_id=6 的测试数据重置为 draft 方便重新测试审批提交通知
UPDATE biz_client_quote SET status = 'draft', update_time = sysdate() WHERE quote_id = 6 AND status = '10';

21
sql/bid_notify_fix2.sql Normal file
View File

@@ -0,0 +1,21 @@
-- ════════════════════════════════════════════════════════════════════
-- 消息通知中心 - 修复脚本 #2
-- 修复内容:
-- 1. 修复现有通知 is_read=NULL 的问题导致铃铛未读数为0
-- 2. 将消息通知中心菜单从"系统管理"下移到"系统配置"下,与"待我审批"同级
-- ════════════════════════════════════════════════════════════════════
SET NAMES utf8mb4;
-- 1. 修复现有数据的 is_read=NULL → '0'(新消息默认未读)
UPDATE biz_notify_message SET is_read = '0' WHERE is_read IS NULL OR is_read = '';
-- 2. 移动"消息通知中心"菜单从"系统管理"(parent_id=1) 到"系统配置"(parent_id=2130)
-- 放到"待我审批"(order_num=99) 之后order_num=100
UPDATE sys_menu SET parent_id = 2130, order_num = 100
WHERE menu_id = 2170 AND parent_id = 1;
-- 3. 为 admin 角色重新授权(菜单路径变了,权限不受影响,但刷新一下角色-菜单关联)
-- 注: role_id=1(admin) 的关联已在初始化 SQL 中添加,这里不做重复插入
-- 4. 清理之前插入的测试通知记录is_read 已修复,保留即可)
-- 无需操作

17
sql/bid_notify_fix3.sql Normal file
View File

@@ -0,0 +1,17 @@
-- ════════════════════════════════════════════════════════════════════
-- 消息通知中心 - 修复脚本 #3
-- 修复内容:
-- 1. 修复通知菜单路径为绝对路径 /bid/notify解决"查看全部" 404
-- 2. 修复所有通知的 bizUrl 跳转路径(解决"查看详情" 404
-- ════════════════════════════════════════════════════════════════════
SET NAMES utf8mb4;
-- 1. 修复通知菜单路径为绝对路径,使 /bid/notify 路由生效
UPDATE sys_menu SET path = '/bid/notify' WHERE menu_id = 2170;
-- 2. 修复现有通知的 bizUrl将旧路径改为正确路径
UPDATE biz_notify_message SET biz_url = REPLACE(biz_url, '/bid/purchaseorder?id=', '/quote/purchaseorder?id=') WHERE biz_url LIKE '/bid/purchaseorder?id=%';
UPDATE biz_notify_message SET biz_url = REPLACE(biz_url, '/bid/clientquote?id=', '/bid/clientquote/detail?id=') WHERE biz_url LIKE '/bid/clientquote?id=%';
UPDATE biz_notify_message SET biz_url = REPLACE(biz_url, '/bid/quotation?quotationId=', '/quote/quotation?quotationId=') WHERE biz_url LIKE '/bid/quotation?quotationId=%';
UPDATE biz_notify_message SET biz_url = REPLACE(biz_url, '/bid/order/pending?id=', '/bid/order/pending?id=') WHERE biz_url LIKE '/bid/order/pending?id=%';
UPDATE biz_notify_message SET biz_url = REPLACE(biz_url, '/bid/objection?id=', '/bid/order/objection?id=') WHERE biz_url LIKE '/bid/objection?id=%';