#
Junjie
23 小时以前 86e79681da9c98bd08bd1f2be0c6dbd3f3dd3159
#
6个文件已添加
3个文件已修改
1527 ■■■■■ 已修改文件
src/main/java/com/zy/asrs/controller/NotifyReportController.java 414 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/NotifySendResult.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/param/NotifyResendParam.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/domain/vo/NotifyReportVo.java 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/service/NotifyAsyncService.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/utils/NotifyUtils.java 52 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260307_add_notify_report_menu.sql 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/notifyReport/notifyReport.html 839 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/controller/NotifyReportController.java
New file
@@ -0,0 +1,414 @@
package com.zy.asrs.controller;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.mapper.EntityWrapper;
import com.baomidou.mybatisplus.plugins.Page;
import com.core.annotations.ManagerAuth;
import com.core.common.Cools;
import com.core.common.R;
import com.zy.asrs.domain.NotifyDto;
import com.zy.asrs.domain.NotifySendResult;
import com.zy.asrs.domain.param.NotifyResendParam;
import com.zy.asrs.domain.vo.NotifyReportVo;
import com.zy.asrs.entity.HttpRequestLog;
import com.zy.asrs.service.HttpRequestLogService;
import com.zy.asrs.service.NotifyAsyncService;
import com.zy.asrs.utils.NotifyUtils;
import com.zy.common.utils.RedisUtil;
import com.zy.common.web.BaseController;
import com.zy.system.entity.Config;
import com.zy.system.service.ConfigService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@RestController
public class NotifyReportController extends BaseController {
    @Autowired
    private RedisUtil redisUtil;
    @Autowired
    private NotifyUtils notifyUtils;
    @Autowired
    private NotifyAsyncService notifyAsyncService;
    @Autowired
    private HttpRequestLogService httpRequestLogService;
    @Autowired
    private ConfigService configService;
    @RequestMapping(value = "/notifyReport/summary/auth")
    @ManagerAuth
    public R summary() {
        Map<String, Object> result = new HashMap<>();
        String notifyEnable = getConfigValue("notifyEnable");
        String notifyUri = getConfigValue("notifyUri");
        String notifyUriPath = getConfigValue("notifyUriPath");
        String endpoint = buildNotifyEndpoint(notifyUri, notifyUriPath);
        result.put("notifyEnable", notifyEnable);
        result.put("notifyEnable$", "Y".equalsIgnoreCase(notifyEnable) ? "已开启" : "已关闭");
        result.put("notifyUri", notifyUri);
        result.put("notifyUriPath", notifyUriPath);
        result.put("notifyEndpoint", endpoint);
        result.put("queueCount", loadQueueRecords(null, null).size());
        if (!Cools.isEmpty(endpoint)) {
            result.put("logCount", httpRequestLogService.selectCount(new EntityWrapper<HttpRequestLog>().eq("name", endpoint)));
        } else {
            result.put("logCount", 0);
        }
        return R.ok(result);
    }
    @RequestMapping(value = "/notifyReport/queue/list/auth")
    @ManagerAuth
    public R queueList(@RequestParam(defaultValue = "1") Integer curr,
                       @RequestParam(defaultValue = "15") Integer limit,
                       @RequestParam(required = false) String notifyType,
                       @RequestParam(required = false) Integer device,
                       @RequestParam(required = false) String taskNo,
                       @RequestParam(required = false) String superTaskNo,
                       @RequestParam(required = false) String msgType,
                       @RequestParam(required = false) String condition) {
        List<NotifyReportVo> records = loadQueueRecords(notifyType, device);
        List<NotifyReportVo> filtered = new ArrayList<>();
        for (NotifyReportVo record : records) {
            if (!matchesQueue(record, notifyType, device, taskNo, superTaskNo, msgType, condition)) {
                continue;
            }
            filtered.add(record);
        }
        filtered.sort(Comparator.comparing(NotifyReportVo::getId, Comparator.nullsLast(Long::compareTo)).reversed());
        return R.ok(buildPage(curr, limit, filtered));
    }
    @RequestMapping(value = "/notifyReport/log/list/auth")
    @ManagerAuth
    public R logList(@RequestParam(defaultValue = "1") Integer curr,
                     @RequestParam(defaultValue = "15") Integer limit,
                     @RequestParam(required = false) String notifyType,
                     @RequestParam(required = false) Integer device,
                     @RequestParam(required = false) String taskNo,
                     @RequestParam(required = false) String superTaskNo,
                     @RequestParam(required = false) String msgType,
                     @RequestParam(required = false) Integer result,
                     @RequestParam(required = false) String condition) {
        String endpoint = buildNotifyEndpoint(getConfigValue("notifyUri"), getConfigValue("notifyUriPath"));
        EntityWrapper<HttpRequestLog> wrapper = new EntityWrapper<>();
        if (!Cools.isEmpty(endpoint)) {
            wrapper.eq("name", endpoint);
        } else {
            wrapper.like("request", "\"notifyType\"");
        }
        if (!Cools.isEmpty(taskNo)) {
            wrapper.like("request", taskNo);
        }
        if (!Cools.isEmpty(superTaskNo)) {
            wrapper.like("request", superTaskNo);
        }
        if (!Cools.isEmpty(msgType)) {
            wrapper.like("request", msgType);
        }
        if (!Cools.isEmpty(notifyType)) {
            wrapper.like("request", "\"notifyType\":\"" + notifyType + "\"");
        }
        if (device != null) {
            wrapper.like("request", "\"device\":" + device);
        }
        if (!Cools.isEmpty(condition)) {
            wrapper.andNew().like("request", condition).or().like("response", condition);
        }
        if (result != null) {
            wrapper.eq("result", result);
        }
        wrapper.orderBy("create_time", false);
        Page<HttpRequestLog> logPage = httpRequestLogService.selectPage(new Page<>(curr, limit), wrapper);
        Page<NotifyReportVo> resultPage = new Page<>(curr, limit);
        resultPage.setTotal(logPage.getTotal());
        List<NotifyReportVo> rows = new ArrayList<>();
        for (HttpRequestLog log : logPage.getRecords()) {
            rows.add(buildLogVo(log));
        }
        resultPage.setRecords(rows);
        return R.ok(resultPage);
    }
    @RequestMapping(value = "/notifyReport/resend/auth")
    @ManagerAuth
    public R resend(@RequestBody NotifyResendParam param) {
        if (param == null || Cools.isEmpty(param.getSourceType())) {
            return R.error("补发参数不能为空");
        }
        String notifyUri = getConfigValue("notifyUri");
        String notifyUriPath = getConfigValue("notifyUriPath");
        if (Cools.isEmpty(notifyUri) || Cools.isEmpty(notifyUriPath)) {
            return R.error("请先配置 notifyUri 和 notifyUriPath");
        }
        List<Map<String, Object>> details = new ArrayList<>();
        int successCount = 0;
        int failCount = 0;
        if ("queue".equalsIgnoreCase(param.getSourceType())) {
            if (Cools.isEmpty(param.getRedisKeys())) {
                return R.error("请选择要补发的队列通知");
            }
            for (String redisKey : param.getRedisKeys()) {
                Map<String, Object> detail = resendQueue(redisKey, notifyUri, notifyUriPath);
                details.add(detail);
                if (Boolean.TRUE.equals(detail.get("success"))) {
                    successCount++;
                } else {
                    failCount++;
                }
            }
        } else if ("log".equalsIgnoreCase(param.getSourceType())) {
            if (Cools.isEmpty(param.getLogIds())) {
                return R.error("请选择要补发的通知日志");
            }
            for (Long logId : param.getLogIds()) {
                Map<String, Object> detail = resendLog(logId, notifyUri, notifyUriPath);
                details.add(detail);
                if (Boolean.TRUE.equals(detail.get("success"))) {
                    successCount++;
                } else {
                    failCount++;
                }
            }
        } else {
            return R.error("不支持的补发来源");
        }
        Map<String, Object> result = new HashMap<>();
        result.put("successCount", successCount);
        result.put("failCount", failCount);
        result.put("allSuccess", failCount == 0);
        result.put("details", details);
        return R.ok(result);
    }
    private Page<NotifyReportVo> buildPage(Integer curr, Integer limit, List<NotifyReportVo> records) {
        Page<NotifyReportVo> page = new Page<>(curr, limit);
        page.setTotal(records.size());
        int fromIndex = Math.max((curr - 1) * limit, 0);
        if (fromIndex >= records.size()) {
            page.setRecords(new ArrayList<>());
            return page;
        }
        int toIndex = Math.min(fromIndex + limit, records.size());
        page.setRecords(new ArrayList<>(records.subList(fromIndex, toIndex)));
        return page;
    }
    private List<NotifyReportVo> loadQueueRecords(String notifyType, Integer device) {
        Set<String> keys = new LinkedHashSet<>();
        List<String> notifyTypes = new ArrayList<>();
        if (!Cools.isEmpty(notifyType)) {
            notifyTypes.add(notifyType);
        } else {
            notifyTypes.addAll(notifyUtils.getSupportedNotifyTypes());
        }
        for (String item : notifyTypes) {
            String prefix = notifyUtils.getKeyPrefix(item);
            if (Cools.isEmpty(prefix)) {
                continue;
            }
            String pattern = device == null ? prefix + "*" : prefix + device + "_*";
            Set matched = redisUtil.keys(pattern);
            if (matched == null) {
                continue;
            }
            for (Object key : matched) {
                if (key != null) {
                    keys.add(String.valueOf(key));
                }
            }
        }
        List<NotifyReportVo> rows = new ArrayList<>();
        for (String key : keys) {
            Object object = redisUtil.get(key);
            if (!(object instanceof NotifyDto)) {
                continue;
            }
            rows.add(buildQueueVo(key, (NotifyDto) object));
        }
        return rows;
    }
    private NotifyReportVo buildQueueVo(String redisKey, NotifyDto dto) {
        NotifyReportVo vo = new NotifyReportVo();
        vo.setSourceType("queue");
        vo.setRedisKey(redisKey);
        vo.setId(dto.getId());
        vo.setNotifyType(dto.getNotifyType());
        vo.setDevice(dto.getDevice());
        vo.setTaskNo(dto.getTaskNo());
        vo.setSuperTaskNo(dto.getSuperTaskNo());
        vo.setMsgType(dto.getMsgType());
        vo.setMsgDesc(dto.getMsgDesc());
        vo.setData(dto.getData());
        vo.setFailTimes(dto.getFailTimes());
        vo.setRetryTimes(dto.getRetryTimes());
        vo.setRetryTime(dto.getRetryTime());
        vo.setLastRetryTime(dto.getLastRetryTime());
        vo.setRequestPayload(JSON.toJSONString(dto));
        return vo;
    }
    private NotifyReportVo buildLogVo(HttpRequestLog log) {
        NotifyReportVo vo = new NotifyReportVo();
        vo.setSourceType("log");
        vo.setLogId(log.getId());
        vo.setCreateTime(log.getCreateTime());
        vo.setResult(log.getResult());
        vo.setResponse(log.getResponse());
        vo.setRequestPayload(log.getRequest());
        NotifyDto dto = parseNotifyDto(log.getRequest());
        if (dto != null) {
            vo.setId(dto.getId());
            vo.setNotifyType(dto.getNotifyType());
            vo.setDevice(dto.getDevice());
            vo.setTaskNo(dto.getTaskNo());
            vo.setSuperTaskNo(dto.getSuperTaskNo());
            vo.setMsgType(dto.getMsgType());
            vo.setMsgDesc(dto.getMsgDesc());
            vo.setData(dto.getData());
            vo.setFailTimes(dto.getFailTimes());
            vo.setRetryTimes(dto.getRetryTimes());
            vo.setRetryTime(dto.getRetryTime());
            vo.setLastRetryTime(dto.getLastRetryTime());
        } else {
            vo.setData(log.getRequest());
        }
        return vo;
    }
    private boolean matchesQueue(NotifyReportVo record, String notifyType, Integer device, String taskNo,
                                 String superTaskNo, String msgType, String condition) {
        if (!Cools.isEmpty(notifyType) && !notifyType.equals(record.getNotifyType())) {
            return false;
        }
        if (device != null && !device.equals(record.getDevice())) {
            return false;
        }
        if (!containsValue(record.getTaskNo(), taskNo)) {
            return false;
        }
        if (!containsValue(record.getSuperTaskNo(), superTaskNo)) {
            return false;
        }
        if (!containsValue(record.getMsgType(), msgType) && !containsValue(record.getMsgDesc(), msgType)) {
            return false;
        }
        if (Cools.isEmpty(condition)) {
            return true;
        }
        return containsValue(record.getTaskNo(), condition)
                || containsValue(record.getSuperTaskNo(), condition)
                || containsValue(record.getMsgType(), condition)
                || containsValue(record.getMsgDesc(), condition)
                || containsValue(record.getData(), condition)
                || containsValue(record.getNotifyType$(), condition)
                || containsValue(record.getRedisKey(), condition);
    }
    private boolean containsValue(String source, String target) {
        if (Cools.isEmpty(target)) {
            return true;
        }
        if (Cools.isEmpty(source)) {
            return false;
        }
        return source.contains(target);
    }
    private Map<String, Object> resendQueue(String redisKey, String notifyUri, String notifyUriPath) {
        Map<String, Object> detail = new HashMap<>();
        detail.put("sourceType", "queue");
        detail.put("redisKey", redisKey);
        Object object = redisUtil.get(redisKey);
        if (!(object instanceof NotifyDto)) {
            detail.put("success", false);
            detail.put("message", "队列通知不存在或已被消费");
            return detail;
        }
        NotifyDto notifyDto = (NotifyDto) object;
        NotifySendResult result = notifyAsyncService.sendNotifyNow(notifyUri, notifyUriPath, redisKey, notifyDto, true, false);
        detail.put("success", result.isSuccess());
        detail.put("message", result.getMessage());
        detail.put("taskNo", notifyDto.getTaskNo());
        detail.put("superTaskNo", notifyDto.getSuperTaskNo());
        detail.put("response", result.getResponse());
        return detail;
    }
    private Map<String, Object> resendLog(Long logId, String notifyUri, String notifyUriPath) {
        Map<String, Object> detail = new HashMap<>();
        detail.put("sourceType", "log");
        detail.put("logId", logId);
        HttpRequestLog log = httpRequestLogService.selectById(logId);
        if (log == null) {
            detail.put("success", false);
            detail.put("message", "通知日志不存在");
            return detail;
        }
        NotifyDto notifyDto = parseNotifyDto(log.getRequest());
        if (notifyDto == null) {
            detail.put("success", false);
            detail.put("message", "日志请求报文无法解析为通知对象");
            return detail;
        }
        NotifySendResult result = notifyAsyncService.sendNotifyNow(notifyUri, notifyUriPath, null, notifyDto, false, false);
        detail.put("success", result.isSuccess());
        detail.put("message", result.getMessage());
        detail.put("taskNo", notifyDto.getTaskNo());
        detail.put("superTaskNo", notifyDto.getSuperTaskNo());
        detail.put("response", result.getResponse());
        return detail;
    }
    private NotifyDto parseNotifyDto(String request) {
        if (Cools.isEmpty(request)) {
            return null;
        }
        try {
            return JSON.parseObject(request, NotifyDto.class);
        } catch (Exception ignored) {
            return null;
        }
    }
    private String getConfigValue(String code) {
        Config config = configService.selectOne(new EntityWrapper<Config>().eq("code", code));
        return config == null ? null : config.getValue();
    }
    private String buildNotifyEndpoint(String notifyUri, String notifyUriPath) {
        if (Cools.isEmpty(notifyUri) || Cools.isEmpty(notifyUriPath)) {
            return null;
        }
        return notifyUri + notifyUriPath;
    }
}
src/main/java/com/zy/asrs/domain/NotifySendResult.java
New file
@@ -0,0 +1,13 @@
package com.zy.asrs.domain;
import lombok.Data;
@Data
public class NotifySendResult {
    private boolean success;
    private String response;
    private String message;
}
src/main/java/com/zy/asrs/domain/param/NotifyResendParam.java
New file
@@ -0,0 +1,15 @@
package com.zy.asrs.domain.param;
import lombok.Data;
import java.util.List;
@Data
public class NotifyResendParam {
    private String sourceType;
    private List<String> redisKeys;
    private List<Long> logIds;
}
src/main/java/com/zy/asrs/domain/vo/NotifyReportVo.java
New file
@@ -0,0 +1,109 @@
package com.zy.asrs.domain.vo;
import com.core.common.Cools;
import lombok.Data;
import java.text.SimpleDateFormat;
import java.util.Date;
@Data
public class NotifyReportVo {
    private String sourceType;
    private String redisKey;
    private Long logId;
    private Long id;
    private String notifyType;
    private Integer device;
    private String taskNo;
    private String superTaskNo;
    private String msgType;
    private String msgDesc;
    private String data;
    private String requestPayload;
    private Integer failTimes;
    private Integer retryTimes;
    private Integer retryTime;
    private Long lastRetryTime;
    private Date createTime;
    private Integer result;
    private String response;
    public String getNotifyType$() {
        if (Cools.isEmpty(this.notifyType)) {
            return "";
        }
        if ("Crn".equals(this.notifyType)) {
            return "堆垛机";
        }
        if ("Devp".equals(this.notifyType)) {
            return "输送线";
        }
        if ("DualCrn".equals(this.notifyType)) {
            return "双伸位堆垛机";
        }
        if ("Rgv".equals(this.notifyType)) {
            return "RGV";
        }
        if ("task".equals(this.notifyType)) {
            return "任务";
        }
        return this.notifyType;
    }
    public String getRetryProgress$() {
        if (this.retryTimes == null && this.failTimes == null) {
            return "";
        }
        return (this.retryTimes == null ? 0 : this.retryTimes) + "/" + (this.failTimes == null ? 0 : this.failTimes);
    }
    public String getQueueStatus$() {
        if (!"queue".equals(this.sourceType)) {
            return "";
        }
        if (this.retryTimes == null || this.retryTimes == 0) {
            return "待发送";
        }
        return "重试中";
    }
    public String getResult$() {
        if (this.result == null) {
            return "";
        }
        return this.result == 1 ? "成功" : "失败";
    }
    public String getLastRetryTime$() {
        if (this.lastRetryTime == null || this.lastRetryTime <= 0L) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date(this.lastRetryTime));
    }
    public String getCreateTime$() {
        if (this.createTime == null) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.createTime);
    }
}
src/main/java/com/zy/asrs/service/NotifyAsyncService.java
@@ -3,6 +3,7 @@
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.zy.asrs.domain.NotifyDto;
import com.zy.asrs.domain.NotifySendResult;
import com.zy.asrs.entity.HttpRequestLog;
import com.zy.common.utils.HttpHandler;
import com.zy.common.utils.RedisUtil;
@@ -36,40 +37,51 @@
     */
    @Async
    public void sendNotifyAsync(String notifyUri, String notifyUriPath, String key, NotifyDto notifyDto) {
        sendNotifyNow(notifyUri, notifyUriPath, key, notifyDto, true, true);
    }
    public NotifySendResult sendNotifyNow(String notifyUri, String notifyUriPath, String key, NotifyDto notifyDto,
                                          boolean deleteOnSuccess, boolean updateRetryState) {
        HttpRequestLog httpRequestLog = new HttpRequestLog();
        httpRequestLog.setName(notifyUri + notifyUriPath);
        httpRequestLog.setRequest(JSON.toJSONString(notifyDto));
        httpRequestLog.setCreateTime(new Date());
        boolean success = false;
        NotifySendResult result = new NotifySendResult();
        result.setSuccess(false);
        try {
            // 触发通知
            String response = new HttpHandler.Builder()
                    .setUri(notifyUri)
                    .setPath(notifyUriPath)
                    .setJson(JSON.toJSONString(notifyDto))
                    .build()
                    .doPost();
            result.setResponse(response);
            httpRequestLog.setResponse(response);
            JSONObject jsonObject = JSON.parseObject(response);
            Integer code = jsonObject.getInteger("code");
            if (code == 200) {
                // 通知成功
                redisUtil.del(key);
                success = true;
            Integer code = jsonObject == null ? null : jsonObject.getInteger("code");
            if (code != null && code == 200) {
                if (deleteOnSuccess && key != null) {
                    redisUtil.del(key);
                }
                result.setSuccess(true);
                result.setMessage("通知成功");
            } else {
                result.setMessage("通知接口返回失败");
            }
        } catch (Exception e) {
            log.error("异步通知失败, key={}", key, e);
            result.setMessage("通知异常: " + e.getMessage());
        } finally {
            // 保存记录
            httpRequestLog.setResult(result.isSuccess() ? 1 : 0);
            httpRequestLogService.insert(httpRequestLog);
        }
        if (!success) {
            // 通知失败,更新重试次数
        if (!result.isSuccess() && updateRetryState && key != null) {
            handleNotifyFailure(key, notifyDto);
        }
        return result;
    }
    /**
src/main/java/com/zy/asrs/utils/NotifyUtils.java
@@ -13,6 +13,7 @@
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
@@ -40,7 +41,7 @@
            return null;
        }
        Set keys = redisUtil.keys(key + "*");
        Set keys = redisUtil.keys(key + "_*");
        if (keys == null) {
            return null;
        }
@@ -52,23 +53,52 @@
        return list;
    }
    public String getKey(String notifyType, Integer device) {
        String key = null;
    public List<String> getSupportedNotifyTypes() {
        return Arrays.asList(
                String.valueOf(SlaveType.Crn),
                String.valueOf(SlaveType.Devp),
                String.valueOf(SlaveType.DualCrn),
                String.valueOf(SlaveType.Rgv),
                "task"
        );
    }
    public String getKeyPrefix(String notifyType) {
        if (notifyType.equals(String.valueOf(SlaveType.Crn))) {
            key = RedisKeyType.QUEUE_CRN.key + device;
            return RedisKeyType.QUEUE_CRN.key;
        } else if (notifyType.equals(String.valueOf(SlaveType.Devp))) {
            key = RedisKeyType.QUEUE_DEVP.key + device;
            return RedisKeyType.QUEUE_DEVP.key;
        } else if (notifyType.equals(String.valueOf(SlaveType.DualCrn))) {
            key = RedisKeyType.QUEUE_DUAL_CRN.key + device;
            return RedisKeyType.QUEUE_DUAL_CRN.key;
        } else if (notifyType.equals(String.valueOf(SlaveType.Rgv))) {
            key = RedisKeyType.QUEUE_RGV.key + device;
            return RedisKeyType.QUEUE_RGV.key;
        } else if (notifyType.equals("task")) {
            key = RedisKeyType.QUEUE_TASK.key + device;
        } else {
            return RedisKeyType.QUEUE_TASK.key;
        }
        return null;
    }
    public String getNotifyTypeDesc(String notifyType) {
        if (notifyType.equals(String.valueOf(SlaveType.Crn))) {
            return "堆垛机";
        } else if (notifyType.equals(String.valueOf(SlaveType.Devp))) {
            return "输送线";
        } else if (notifyType.equals(String.valueOf(SlaveType.DualCrn))) {
            return "双伸位堆垛机";
        } else if (notifyType.equals(String.valueOf(SlaveType.Rgv))) {
            return "RGV";
        } else if (notifyType.equals("task")) {
            return "任务";
        }
        return notifyType;
    }
    public String getKey(String notifyType, Integer device) {
        String prefix = getKeyPrefix(notifyType);
        if (prefix == null) {
            return null;
        }
        return key;
        return prefix + device;
    }
    private boolean append(String notifyType, Integer device, String taskNo, String superTaskNo, NotifyMsgType msgType, String data) {
src/main/resources/application.yml
@@ -1,6 +1,6 @@
# 系统版本信息
app:
  version: 1.0.5.0
  version: 1.0.5.1
  version-type: dev  # prd 或 dev
server:
src/main/resources/sql/20260307_add_notify_report_menu.sql
New file
@@ -0,0 +1,51 @@
-- 将 通知上报 菜单挂载到:日志报表(优先)或开发专用
-- 说明:执行本脚本后,请在“角色授权”里给对应角色勾选新菜单和“查看”权限。
SET @notify_parent_id := COALESCE(
  (
    SELECT id
    FROM sys_resource
    WHERE code = 'logReport' AND level = 1
    ORDER BY id
    LIMIT 1
  ),
  (
    SELECT id
    FROM sys_resource
    WHERE code = 'develop' AND level = 1
    ORDER BY id
    LIMIT 1
  )
);
INSERT INTO sys_resource(code, name, resource_id, level, sort, status)
SELECT 'notifyReport/notifyReport.html', '通知上报', @notify_parent_id, 2, 998, 1
FROM dual
WHERE @notify_parent_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1
    FROM sys_resource
    WHERE code = 'notifyReport/notifyReport.html' AND level = 2
  );
SET @notify_report_id := (
  SELECT id
  FROM sys_resource
  WHERE code = 'notifyReport/notifyReport.html' AND level = 2
  ORDER BY id
  LIMIT 1
);
INSERT INTO sys_resource(code, name, resource_id, level, sort, status)
SELECT 'notifyReport/notifyReport.html#view', '查看', @notify_report_id, 3, 1, 1
FROM dual
WHERE @notify_report_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1
    FROM sys_resource
    WHERE code = 'notifyReport/notifyReport.html#view' AND level = 3
  );
SELECT id, code, name, resource_id, level, sort, status
FROM sys_resource
WHERE code IN ('notifyReport/notifyReport.html', 'notifyReport/notifyReport.html#view');
src/main/webapp/views/notifyReport/notifyReport.html
New file
@@ -0,0 +1,839 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>通知上报</title>
  <link rel="stylesheet" href="../../static/vue/element/element.css" />
  <style>
    [v-cloak] {
      display: none;
    }
    html,
    body {
      margin: 0;
      min-height: 100%;
      font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif;
      background:
        radial-gradient(1200px 420px at 0% -5%, rgba(36, 91, 160, 0.14), transparent 55%),
        radial-gradient(1000px 500px at 100% 0%, rgba(35, 147, 120, 0.11), transparent 60%),
        linear-gradient(180deg, #f2f6fb 0%, #edf2f7 100%);
      color: #243447;
    }
    .page-shell {
      max-width: 1680px;
      margin: 0 auto;
      padding: 16px;
      box-sizing: border-box;
    }
    .hero {
      border-radius: 18px;
      padding: 18px 20px 16px;
      color: #fff;
      background: linear-gradient(135deg, #114a7a 0%, #226ca1 44%, #1f9c8c 100%);
      box-shadow: 0 18px 36px rgba(17, 74, 122, 0.22);
      margin-bottom: 14px;
      overflow: hidden;
      position: relative;
    }
    .hero::after {
      content: "";
      position: absolute;
      right: -60px;
      top: -90px;
      width: 280px;
      height: 280px;
      border-radius: 50%;
      background: rgba(255, 255, 255, 0.08);
      filter: blur(2px);
    }
    .hero-head {
      position: relative;
      z-index: 1;
      display: flex;
      align-items: flex-start;
      justify-content: space-between;
      gap: 12px;
      flex-wrap: wrap;
    }
    .hero-title {
      display: flex;
      flex-direction: column;
      gap: 6px;
    }
    .hero-title-main {
      font-size: 22px;
      font-weight: 700;
      letter-spacing: 0.4px;
    }
    .hero-title-sub {
      font-size: 13px;
      opacity: 0.9;
      max-width: 760px;
      line-height: 1.6;
    }
    .hero-actions {
      display: flex;
      align-items: center;
      gap: 10px;
      flex-wrap: wrap;
    }
    .summary-grid {
      position: relative;
      z-index: 1;
      margin-top: 16px;
      display: grid;
      grid-template-columns: repeat(4, minmax(0, 1fr));
      gap: 10px;
    }
    .summary-card {
      min-height: 78px;
      border-radius: 14px;
      padding: 12px 14px;
      background: rgba(255, 255, 255, 0.15);
      border: 1px solid rgba(255, 255, 255, 0.22);
      backdrop-filter: blur(3px);
    }
    .summary-card .label {
      font-size: 12px;
      opacity: 0.86;
    }
    .summary-card .value {
      margin-top: 7px;
      font-size: 24px;
      font-weight: 700;
      line-height: 1.15;
      word-break: break-all;
    }
    .summary-card .sub {
      margin-top: 6px;
      font-size: 12px;
      opacity: 0.88;
      word-break: break-all;
    }
    .panel {
      border-radius: 18px;
      border: 1px solid #dbe4ef;
      background:
        radial-gradient(700px 220px at -10% 0%, rgba(41, 112, 196, 0.05), transparent 55%),
        radial-gradient(860px 260px at 110% 16%, rgba(31, 156, 140, 0.06), transparent 58%),
        rgba(250, 252, 255, 0.88);
      box-shadow: 0 16px 32px rgba(39, 63, 92, 0.08);
      overflow: hidden;
    }
    .toolbar-card {
      padding: 16px 16px 6px;
      margin-bottom: 14px;
    }
    .toolbar-title {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 10px;
      margin-bottom: 14px;
      flex-wrap: wrap;
    }
    .toolbar-title .main {
      font-size: 16px;
      font-weight: 700;
      color: #243447;
    }
    .toolbar-title .sub {
      font-size: 12px;
      color: #7b8ba1;
    }
    .filter-grid {
      display: grid;
      grid-template-columns: repeat(6, minmax(0, 1fr));
      gap: 12px;
    }
    .filter-item.full {
      grid-column: span 2;
    }
    .filter-actions {
      margin-top: 14px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      flex-wrap: wrap;
    }
    .filter-actions-left,
    .filter-actions-right {
      display: flex;
      align-items: center;
      gap: 10px;
      flex-wrap: wrap;
    }
    .board {
      padding: 14px;
    }
    .board-head {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 10px;
      margin-bottom: 12px;
      flex-wrap: wrap;
    }
    .board-head .title {
      font-size: 15px;
      font-weight: 700;
      color: #26384d;
    }
    .board-head .desc {
      font-size: 12px;
      color: #8594a8;
    }
    .board .el-tabs__nav-wrap::after {
      background: rgba(222, 230, 238, 0.86);
    }
    .table-shell {
      border-radius: 16px;
      overflow: hidden;
      border: 1px solid #e0e8f2;
      background: rgba(255, 255, 255, 0.9);
    }
    .table-toolbar {
      padding: 12px 12px 0;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 10px;
      flex-wrap: wrap;
    }
    .table-toolbar-left,
    .table-toolbar-right {
      display: flex;
      align-items: center;
      gap: 10px;
      flex-wrap: wrap;
    }
    .table-note {
      font-size: 12px;
      color: #8090a4;
    }
    .code-text {
      font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
      font-size: 12px;
    }
    .payload-box {
      max-height: 62vh;
      overflow: auto;
      border-radius: 12px;
      border: 1px solid #d9e4f0;
      background: linear-gradient(180deg, #fbfdff 0%, #f4f8fc 100%);
      padding: 14px;
      margin: 0;
      white-space: pre-wrap;
      word-break: break-word;
      line-height: 1.65;
      color: #28405a;
      font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
      font-size: 12px;
    }
    .pagination-wrap {
      padding: 12px 14px 14px;
      display: flex;
      justify-content: flex-end;
      background: rgba(255, 255, 255, 0.72);
    }
    .empty-hint {
      padding: 34px 12px;
      text-align: center;
      color: #8c9aae;
      font-size: 13px;
    }
    @media (max-width: 1360px) {
      .summary-grid {
        grid-template-columns: repeat(2, minmax(0, 1fr));
      }
      .filter-grid {
        grid-template-columns: repeat(3, minmax(0, 1fr));
      }
      .filter-item.full {
        grid-column: span 1;
      }
    }
    @media (max-width: 900px) {
      .page-shell {
        padding: 10px;
      }
      .summary-grid,
      .filter-grid {
        grid-template-columns: 1fr;
      }
      .hero-title-main {
        font-size: 18px;
      }
      .table-toolbar,
      .filter-actions {
        align-items: flex-start;
      }
      .pagination-wrap {
        justify-content: center;
      }
    }
  </style>
</head>
<body>
<div id="app" class="page-shell" v-cloak>
  <section class="hero">
    <div class="hero-head">
      <div class="hero-title">
        <div class="hero-title-main">通知上报中心</div>
        <div class="hero-title-sub">查看当前待通知队列、接口发送日志,支持按任务/设备快速筛选,并对失败或待发送通知执行手动补发。</div>
      </div>
      <div class="hero-actions">
        <el-button type="primary" icon="el-icon-refresh" :loading="summaryLoading" @click="refreshAll">刷新全局</el-button>
      </div>
    </div>
    <div class="summary-grid">
      <div class="summary-card">
        <div class="label">上报开关</div>
        <div class="value">{{ summary['notifyEnable$'] || '-' }}</div>
        <div class="sub">`notifyEnable` = {{ summary.notifyEnable || '-' }}</div>
      </div>
      <div class="summary-card">
        <div class="label">当前队列数</div>
        <div class="value">{{ summary.queueCount || 0 }}</div>
        <div class="sub">Redis 中待上报或待重试通知</div>
      </div>
      <div class="summary-card">
        <div class="label">通知日志数</div>
        <div class="value">{{ summary.logCount || 0 }}</div>
        <div class="sub">通知接口历史调用记录</div>
      </div>
      <div class="summary-card">
        <div class="label">通知地址</div>
        <div class="value" style="font-size: 16px;">{{ summary.notifyEndpoint || '-' }}</div>
        <div class="sub">来源:notifyUri + notifyUriPath</div>
      </div>
    </div>
  </section>
  <section class="panel toolbar-card">
    <div class="toolbar-title">
      <div>
        <div class="main">筛选条件</div>
        <div class="sub">队列与日志共用同一组查询条件,切换页签时保持一致。</div>
      </div>
    </div>
    <div class="filter-grid">
      <div class="filter-item">
        <el-select v-model="filters.notifyType" clearable placeholder="通知类型" size="medium">
          <el-option label="堆垛机" value="Crn"></el-option>
          <el-option label="双伸位堆垛机" value="DualCrn"></el-option>
          <el-option label="输送线" value="Devp"></el-option>
          <el-option label="RGV" value="Rgv"></el-option>
          <el-option label="任务" value="task"></el-option>
        </el-select>
      </div>
      <div class="filter-item">
        <el-input v-model.trim="filters.device" placeholder="设备号" clearable></el-input>
      </div>
      <div class="filter-item">
        <el-input v-model.trim="filters.taskNo" placeholder="任务号" clearable></el-input>
      </div>
      <div class="filter-item">
        <el-input v-model.trim="filters.superTaskNo" placeholder="上级任务号" clearable></el-input>
      </div>
      <div class="filter-item">
        <el-input v-model.trim="filters.msgType" placeholder="消息类型/描述" clearable></el-input>
      </div>
      <div class="filter-item">
        <el-select v-model="filters.result" clearable placeholder="日志结果">
          <el-option label="成功" :value="1"></el-option>
          <el-option label="失败" :value="0"></el-option>
        </el-select>
      </div>
      <div class="filter-item full">
        <el-input v-model.trim="filters.condition" placeholder="通用搜索:任务号、消息描述、报文关键字、Redis Key" clearable></el-input>
      </div>
    </div>
    <div class="filter-actions">
      <div class="filter-actions-left">
        <el-button type="primary" icon="el-icon-search" :loading="queue.loading || log.loading" @click="handleSearch">查询</el-button>
        <el-button icon="el-icon-refresh-left" @click="handleReset">重置</el-button>
      </div>
      <div class="filter-actions-right">
        <span class="table-note">当前页签:{{ activeTab === 'queue' ? '通知队列' : '发送日志' }}</span>
      </div>
    </div>
  </section>
  <section class="panel board">
    <div class="board-head">
      <div>
        <div class="title">通知查看与补发</div>
        <div class="desc">当前队列显示 Redis 实时数据,发送日志显示历史接口调用结果。</div>
      </div>
    </div>
    <el-tabs v-model="activeTab" @tab-click="handleTabChange">
      <el-tab-pane label="当前通知队列" name="queue">
        <div class="table-shell">
          <div class="table-toolbar">
            <div class="table-toolbar-left">
              <el-button type="primary" size="mini" icon="el-icon-position" :disabled="queue.selection.length === 0" :loading="resendLoading" @click="batchResendQueue">批量补发</el-button>
              <span class="table-note">已选 {{ queue.selection.length }} 条</span>
            </div>
            <div class="table-toolbar-right">
              <span class="table-note">展示待发送和待重试通知</span>
            </div>
          </div>
          <el-table
            :data="queue.records"
            border
            stripe
            height="520"
            v-loading="queue.loading"
            @selection-change="queue.selection = $event"
            :header-cell-style="headerCellStyle">
            <el-table-column type="selection" width="48"></el-table-column>
            <el-table-column prop="id" label="通知ID" min-width="180" show-overflow-tooltip>
              <template slot-scope="scope">
                <span class="code-text">{{ scope.row.id }}</span>
              </template>
            </el-table-column>
            <el-table-column prop="notifyType$" label="通知类型" width="120"></el-table-column>
            <el-table-column prop="device" label="设备号" width="88"></el-table-column>
            <el-table-column prop="taskNo" label="任务号" min-width="120" show-overflow-tooltip></el-table-column>
            <el-table-column prop="superTaskNo" label="上级任务号" min-width="130" show-overflow-tooltip></el-table-column>
            <el-table-column prop="msgDesc" label="消息描述" min-width="180" show-overflow-tooltip></el-table-column>
            <el-table-column prop="retryProgress$" label="重试次数" width="88"></el-table-column>
            <el-table-column prop="retryTime" label="间隔(s)" width="84"></el-table-column>
            <el-table-column prop="lastRetryTime$" label="上次重试时间" width="168"></el-table-column>
            <el-table-column label="队列状态" width="104">
              <template slot-scope="scope">
                <el-tag size="mini" :type="scope.row.queueStatus$ === '待发送' ? 'success' : 'warning'">{{ scope.row.queueStatus$ || '-' }}</el-tag>
              </template>
            </el-table-column>
            <el-table-column label="操作" width="170" fixed="right">
              <template slot-scope="scope">
                <el-button type="text" size="mini" @click="openPayload('通知报文', scope.row.requestPayload)">查看报文</el-button>
                <el-button type="text" size="mini" @click="resendQueueRow(scope.row)">补发</el-button>
              </template>
            </el-table-column>
          </el-table>
          <div v-if="!queue.loading && queue.records.length === 0" class="empty-hint">当前没有待发送通知</div>
          <div class="pagination-wrap">
            <el-pagination
              background
              layout="total, sizes, prev, pager, next, jumper"
              :current-page="queue.page.curr"
              :page-sizes="[15, 30, 50, 100, 200]"
              :page-size="queue.page.limit"
              :total="queue.page.total"
              @size-change="handleQueueSizeChange"
              @current-change="handleQueuePageChange">
            </el-pagination>
          </div>
        </div>
      </el-tab-pane>
      <el-tab-pane label="通知发送日志" name="log">
        <div class="table-shell">
          <div class="table-toolbar">
            <div class="table-toolbar-left">
              <el-button type="primary" size="mini" icon="el-icon-position" :disabled="log.selection.length === 0" :loading="resendLoading" @click="batchResendLog">批量补发</el-button>
              <span class="table-note">已选 {{ log.selection.length }} 条</span>
            </div>
            <div class="table-toolbar-right">
              <span class="table-note">支持从历史日志重新发送通知</span>
            </div>
          </div>
          <el-table
            :data="log.records"
            border
            stripe
            height="520"
            v-loading="log.loading"
            @selection-change="log.selection = $event"
            :header-cell-style="headerCellStyle">
            <el-table-column type="selection" width="48"></el-table-column>
            <el-table-column prop="createTime$" label="发送时间" width="166"></el-table-column>
            <el-table-column label="结果" width="84">
              <template slot-scope="scope">
                <el-tag v-if="scope.row.result === 1" size="mini" type="success">成功</el-tag>
                <el-tag v-else-if="scope.row.result === 0" size="mini" type="danger">失败</el-tag>
                <span v-else>-</span>
              </template>
            </el-table-column>
            <el-table-column prop="notifyType$" label="通知类型" width="120"></el-table-column>
            <el-table-column prop="device" label="设备号" width="88"></el-table-column>
            <el-table-column prop="taskNo" label="任务号" min-width="120" show-overflow-tooltip></el-table-column>
            <el-table-column prop="superTaskNo" label="上级任务号" min-width="130" show-overflow-tooltip></el-table-column>
            <el-table-column prop="msgDesc" label="消息描述" min-width="180" show-overflow-tooltip></el-table-column>
            <el-table-column label="操作" width="250" fixed="right">
              <template slot-scope="scope">
                <el-button type="text" size="mini" @click="openPayload('通知报文', scope.row.requestPayload)">查看报文</el-button>
                <el-button type="text" size="mini" @click="openPayload('接口响应', scope.row.response)">查看响应</el-button>
                <el-button type="text" size="mini" @click="resendLogRow(scope.row)">补发</el-button>
              </template>
            </el-table-column>
          </el-table>
          <div v-if="!log.loading && log.records.length === 0" class="empty-hint">当前筛选条件下没有通知日志</div>
          <div class="pagination-wrap">
            <el-pagination
              background
              layout="total, sizes, prev, pager, next, jumper"
              :current-page="log.page.curr"
              :page-sizes="[15, 30, 50, 100, 200]"
              :page-size="log.page.limit"
              :total="log.page.total"
              @size-change="handleLogSizeChange"
              @current-change="handleLogPageChange">
            </el-pagination>
          </div>
        </div>
      </el-tab-pane>
    </el-tabs>
  </section>
  <el-dialog :title="dialog.title" :visible.sync="dialog.visible" width="72%" :close-on-click-modal="false">
    <pre class="payload-box">{{ dialog.content || '-' }}</pre>
  </el-dialog>
</div>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script>
  new Vue({
    el: '#app',
    data: function () {
      return {
        activeTab: 'queue',
        summaryLoading: false,
        resendLoading: false,
        summary: {},
        filters: {
          notifyType: '',
          device: '',
          taskNo: '',
          superTaskNo: '',
          msgType: '',
          result: null,
          condition: ''
        },
        queue: {
          loading: false,
          records: [],
          selection: [],
          page: {
            curr: 1,
            limit: 15,
            total: 0
          }
        },
        log: {
          loading: false,
          records: [],
          selection: [],
          page: {
            curr: 1,
            limit: 15,
            total: 0
          }
        },
        dialog: {
          visible: false,
          title: '',
          content: ''
        },
        headerCellStyle: {
          background: '#f7f9fc',
          color: '#2d4058',
          fontWeight: 600
        }
      };
    },
    created: function () {
      this.refreshAll();
    },
    methods: {
      buildFilterParams: function (includeResult) {
        var params = {};
        var filters = this.filters;
        if (filters.notifyType) params.notifyType = filters.notifyType;
        if (filters.device !== '' && filters.device !== null && filters.device !== undefined) {
          params.device = filters.device;
        }
        if (filters.taskNo) params.taskNo = filters.taskNo;
        if (filters.superTaskNo) params.superTaskNo = filters.superTaskNo;
        if (filters.msgType) params.msgType = filters.msgType;
        if (filters.condition) params.condition = filters.condition;
        if (includeResult && filters.result !== '' && filters.result !== null && filters.result !== undefined) {
          params.result = filters.result;
        }
        return params;
      },
      buildQueryString: function (params) {
        var search = [];
        Object.keys(params).forEach(function (key) {
          if (params[key] === '' || params[key] === null || params[key] === undefined) {
            return;
          }
          search.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key]));
        });
        return search.join('&');
      },
      fetchJson: function (url, options) {
        var requestOptions = options || {};
        requestOptions.headers = requestOptions.headers || {};
        requestOptions.headers.token = localStorage.getItem('token');
        if (requestOptions.body && !requestOptions.headers['Content-Type']) {
          requestOptions.headers['Content-Type'] = 'application/json;charset=UTF-8';
        }
        return fetch(url, requestOptions).then(function (response) {
          return response.json();
        });
      },
      ensureSuccess: function (res, fallback) {
        if (res && res.code === 403) {
          top.location.href = baseUrl + '/';
          return false;
        }
        if (!res || res.code !== 200) {
          this.$message.error((res && res.msg) || fallback || '请求失败');
          return false;
        }
        return true;
      },
      refreshAll: function () {
        this.loadSummary();
        this.loadQueue();
        this.loadLog();
      },
      loadSummary: function () {
        var self = this;
        self.summaryLoading = true;
        self.fetchJson(baseUrl + '/notifyReport/summary/auth')
          .then(function (res) {
            if (!self.ensureSuccess(res, '获取通知概览失败')) {
              return;
            }
            self.summary = res.data || {};
          })
          .finally(function () {
            self.summaryLoading = false;
          });
      },
      loadQueue: function () {
        var self = this;
        self.queue.loading = true;
        var params = self.buildFilterParams(false);
        params.curr = self.queue.page.curr;
        params.limit = self.queue.page.limit;
        self.fetchJson(baseUrl + '/notifyReport/queue/list/auth?' + self.buildQueryString(params))
          .then(function (res) {
            if (!self.ensureSuccess(res, '获取通知队列失败')) {
              return;
            }
            self.queue.records = (res.data && res.data.records) || [];
            self.queue.page.total = (res.data && res.data.total) || 0;
            self.queue.selection = [];
          })
          .finally(function () {
            self.queue.loading = false;
          });
      },
      loadLog: function () {
        var self = this;
        self.log.loading = true;
        var params = self.buildFilterParams(true);
        params.curr = self.log.page.curr;
        params.limit = self.log.page.limit;
        self.fetchJson(baseUrl + '/notifyReport/log/list/auth?' + self.buildQueryString(params))
          .then(function (res) {
            if (!self.ensureSuccess(res, '获取通知日志失败')) {
              return;
            }
            self.log.records = (res.data && res.data.records) || [];
            self.log.page.total = (res.data && res.data.total) || 0;
            self.log.selection = [];
          })
          .finally(function () {
            self.log.loading = false;
          });
      },
      handleSearch: function () {
        this.queue.page.curr = 1;
        this.log.page.curr = 1;
        this.refreshAll();
      },
      handleReset: function () {
        this.filters = {
          notifyType: '',
          device: '',
          taskNo: '',
          superTaskNo: '',
          msgType: '',
          result: null,
          condition: ''
        };
        this.queue.page.curr = 1;
        this.log.page.curr = 1;
        this.refreshAll();
      },
      handleTabChange: function () {
        if (this.activeTab === 'queue' && this.queue.records.length === 0) {
          this.loadQueue();
          return;
        }
        if (this.activeTab === 'log' && this.log.records.length === 0) {
          this.loadLog();
        }
      },
      handleQueuePageChange: function (page) {
        this.queue.page.curr = page;
        this.loadQueue();
      },
      handleQueueSizeChange: function (size) {
        this.queue.page.limit = size;
        this.queue.page.curr = 1;
        this.loadQueue();
      },
      handleLogPageChange: function (page) {
        this.log.page.curr = page;
        this.loadLog();
      },
      handleLogSizeChange: function (size) {
        this.log.page.limit = size;
        this.log.page.curr = 1;
        this.loadLog();
      },
      openPayload: function (title, content) {
        this.dialog.title = title;
        this.dialog.content = content || '';
        this.dialog.visible = true;
      },
      resendRequest: function (payload) {
        var self = this;
        self.resendLoading = true;
        return self.fetchJson(baseUrl + '/notifyReport/resend/auth', {
          method: 'POST',
          body: JSON.stringify(payload)
        }).then(function (res) {
          if (!self.ensureSuccess(res, '补发失败')) {
            return;
          }
          var data = res.data || {};
          var message = '成功 ' + (data.successCount || 0) + ' 条';
          if ((data.failCount || 0) > 0) {
            message += ',失败 ' + data.failCount + ' 条';
          }
          self.$message({
            type: (data.failCount || 0) > 0 ? 'warning' : 'success',
            message: message
          });
          self.loadSummary();
          self.loadQueue();
          self.loadLog();
          if ((data.failCount || 0) > 0) {
            self.openPayload('补发结果', JSON.stringify(data.details || [], null, 2));
          }
        }).finally(function () {
          self.resendLoading = false;
        });
      },
      confirmResend: function (payload, title) {
        var self = this;
        self.$confirm(title || '确定执行手动补发吗?', '提示', {
          confirmButtonText: '确定',
          cancelButtonText: '取消',
          type: 'warning'
        }).then(function () {
          self.resendRequest(payload);
        }).catch(function () {});
      },
      resendQueueRow: function (row) {
        this.confirmResend({
          sourceType: 'queue',
          redisKeys: [row.redisKey]
        }, '确定补发该队列通知吗?');
      },
      resendLogRow: function (row) {
        this.confirmResend({
          sourceType: 'log',
          logIds: [row.logId]
        }, '确定按该日志重新补发通知吗?');
      },
      batchResendQueue: function () {
        var keys = this.queue.selection.map(function (item) {
          return item.redisKey;
        });
        if (keys.length === 0) {
          this.$message.warning('请选择要补发的队列通知');
          return;
        }
        this.confirmResend({
          sourceType: 'queue',
          redisKeys: keys
        }, '确定批量补发选中的队列通知吗?');
      },
      batchResendLog: function () {
        var ids = this.log.selection.map(function (item) {
          return item.logId;
        });
        if (ids.length === 0) {
          this.$message.warning('请选择要补发的通知日志');
          return;
        }
        this.confirmResend({
          sourceType: 'log',
          logIds: ids
        }, '确定批量补发选中的通知日志吗?');
      }
    }
  });
</script>
</body>
</html>