1
0

4 Commits 5fbffa8f00 ... abe2d1ec88

Autor SHA1 Mensagem Data
  liweimin abe2d1ec88 1、前端页面 há 2 semanas atrás
  liweimin 784a0532f0 1、调试宝终端功能菜单权限sql há 2 semanas atrás
  liweimin f313591db7 1、增加报税口的上行/下行测试报文 há 2 semanas atrás
  liweimin 03409598e3 1、构建通用的Web端->后端消息->设备MQTT,双向json通信 há 2 semanas atrás
27 ficheiros alterados com 1496 adições e 130 exclusões
  1. 41 0
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TsbWebSocketController.java
  2. 23 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/annotation/JsonCmdDownHandler.java
  3. 0 27
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/annotation/JsonCmdHandler.java
  4. 23 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/annotation/JsonCmdUpHandler.java
  5. 98 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/JsonCmdHandlerManager.java
  6. 2 2
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/IJsonCmdUpHandler.java
  7. 3 2
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/JsonBodyDecoder.java
  8. 0 65
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/JsonCmdHandlerManager.java
  9. 4 4
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/service/DeviceLoginService.java
  10. 4 4
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/service/DevicePtService.java
  11. 26 7
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/service/TaxDataUpService.java
  12. 21 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/encoder/json/IJsonCmdDownHandler.java
  13. 39 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/encoder/json/service/TaxDataDownService.java
  14. 20 8
      ruoyi-device/src/main/java/com/ruoyi/device/websocket/TsbWebSocketService.java
  15. 25 3
      ruoyi-device/src/test/java/com/ruoyi/device/EncoderDecoderTest.java
  16. 9 0
      ruoyi-ui/src/api/tsb/ws.js
  17. 7 0
      ruoyi-ui/src/permission.js
  18. 3 1
      ruoyi-ui/src/store/index.js
  19. 32 0
      ruoyi-ui/src/store/modules/tsb.js
  20. 4 0
      ruoyi-ui/src/store/modules/user.js
  21. 13 0
      ruoyi-ui/src/utils/tsbCmdRoute.js
  22. 290 0
      ruoyi-ui/src/utils/tsbWebSocket.js
  23. 234 0
      ruoyi-ui/src/utils/tsbWebSocketTab.js
  24. 71 0
      ruoyi-ui/src/utils/tsbWsRouter.js
  25. 389 0
      ruoyi-ui/src/views/app-common/tax/index.vue
  26. 25 7
      ruoyi-ui/vue.config.js
  27. 90 0
      sql/tsb_3.0.sql

+ 41 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TsbWebSocketController.java

@@ -0,0 +1,41 @@
+package com.ruoyi.web.controller.device;
+
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.SecurityUtils;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.device.domain.model.TsbUserDeviceBind;
+import com.ruoyi.device.mapper.TsbUserDeviceMapper;
+import jakarta.annotation.Resource;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 调试宝 WebSocket连接 辅助接口
+ * 测试是否可以连接
+ *
+ * @author lwm
+ */
+@RestController
+@RequestMapping("/tsb/ws")
+public class TsbWebSocketController extends BaseController
+{
+    @Resource
+    private TsbUserDeviceMapper tsbUserDeviceMapper;
+
+    /**
+     * 当前登录用户的设备绑定信息(建立 WebSocket 前校验)
+     */
+    @GetMapping("/bind")
+    public AjaxResult bindInfo()
+    {
+        Long userId = SecurityUtils.getUserId();
+        TsbUserDeviceBind bind = tsbUserDeviceMapper.selectBindByUserId(userId);
+        if (bind == null || bind.getDeviceSn() == null || StringUtils.isEmpty(bind.getDeviceType()))
+        {
+            return error("当前用户未绑定调试宝设备,无法建立实时连接");
+        }
+        return success(bind);
+    }
+}

+ 23 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/annotation/JsonCmdDownHandler.java

@@ -0,0 +1,23 @@
+package com.ruoyi.device.mqtt.annotation;
+
+import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
+import org.springframework.stereotype.Component;
+
+import java.lang.annotation.*;
+
+/**
+ * JSON Body 业务命令 下行处理器(按 {@link CmdTypeEnum cmdDownType} 注册)
+ *
+ * @author lwm
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Component
+public @interface JsonCmdDownHandler
+{
+    /**
+     * 下行业务命令类型
+     */
+    CmdTypeEnum cmdType();
+}

+ 0 - 27
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/annotation/JsonCmdHandler.java

@@ -1,27 +0,0 @@
-package com.ruoyi.device.mqtt.annotation;
-
-import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
-import org.springframework.stereotype.Component;
-
-import java.lang.annotation.Documented;
-import java.lang.annotation.ElementType;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
-
-/**
- * JSON Body 业务命令处理器(按 {@link CmdTypeEnum} 注册)
- *
- * @author lwm
- */
-@Target(ElementType.TYPE)
-@Retention(RetentionPolicy.RUNTIME)
-@Documented
-@Component
-public @interface JsonCmdHandler
-{
-    /**
-     * 业务命令类型
-     */
-    CmdTypeEnum cmdType();
-}

+ 23 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/annotation/JsonCmdUpHandler.java

@@ -0,0 +1,23 @@
+package com.ruoyi.device.mqtt.annotation;
+
+import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
+import org.springframework.stereotype.Component;
+
+import java.lang.annotation.*;
+
+/**
+ * JSON Body 业务命令 上行处理器(按 {@link CmdTypeEnum cmdUpType} 注册)
+ *
+ * @author lwm
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Component
+public @interface JsonCmdUpHandler
+{
+    /**
+     * 上行业务命令类型
+     */
+    CmdTypeEnum cmdType();
+}

+ 98 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/JsonCmdHandlerManager.java

@@ -0,0 +1,98 @@
+package com.ruoyi.device.mqtt.handler;
+
+import com.ruoyi.device.mqtt.annotation.JsonCmdDownHandler;
+import com.ruoyi.device.mqtt.annotation.JsonCmdUpHandler;
+import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
+import com.ruoyi.device.mqtt.handler.decoder.json.IJsonCmdUpHandler;
+import com.ruoyi.device.mqtt.handler.encoder.json.IJsonCmdDownHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationListener;
+import org.springframework.context.event.ContextRefreshedEvent;
+import org.springframework.stereotype.Component;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 按 {@link CmdTypeEnum} 将 JSON 上行/下行分发
+ *
+ * @author lwm
+ */
+@Component
+public class JsonCmdHandlerManager implements ApplicationListener<ContextRefreshedEvent>
+{
+    private static final Logger log = LoggerFactory.getLogger(JsonCmdHandlerManager.class);
+
+    private final Map<String, IJsonCmdUpHandler> UP_HANDLER_BY_UP_KEY = new HashMap<>();
+    private final Map<String, IJsonCmdUpHandler> UP_HANDLER_BY_BASE_KEY = new HashMap<>();
+
+    private final Map<String, IJsonCmdDownHandler> DOWN_HANDLER_BY_DOWN_KEY = new HashMap<>();
+    private final Map<String, IJsonCmdDownHandler> DOWN_HANDLER_BY_BASE_KEY = new HashMap<>();
+
+    /**
+     * 监听spring容器启动完成,将所有JsonCmdHandler缓存起来
+     *
+     * @param event 上下文
+     */
+    @Override
+    public void onApplicationEvent(ContextRefreshedEvent event)
+    {
+        ApplicationContext ctx = event.getApplicationContext();
+
+        Map<String, Object> jsonCmdUpHandler = ctx.getBeansWithAnnotation(JsonCmdUpHandler.class);
+        for (Object bean : jsonCmdUpHandler.values())
+        {
+            JsonCmdUpHandler annotation = bean.getClass().getAnnotation(JsonCmdUpHandler.class);
+            CmdTypeEnum cmdTypeEnum = annotation.cmdType();
+            UP_HANDLER_BY_UP_KEY.put(cmdTypeEnum.getCmdUpType(), (IJsonCmdUpHandler) bean);
+            UP_HANDLER_BY_BASE_KEY.put(cmdTypeEnum.getCmdType(), (IJsonCmdUpHandler) bean);
+            log.info("加载 JSON 命令上行处理器 upKey={}, baseKey={}, handler={}",
+                    cmdTypeEnum.getCmdUpType(), cmdTypeEnum.getCmdType(), bean.getClass().getSimpleName());
+        }
+
+        Map<String, Object> jsonCmdDownHandler = ctx.getBeansWithAnnotation(JsonCmdDownHandler.class);
+        for (Object bean : jsonCmdDownHandler.values())
+        {
+            JsonCmdDownHandler annotation = bean.getClass().getAnnotation(JsonCmdDownHandler.class);
+            CmdTypeEnum cmdTypeEnum = annotation.cmdType();
+            DOWN_HANDLER_BY_DOWN_KEY.put(cmdTypeEnum.getCmdDownType(), (IJsonCmdDownHandler) bean);
+            DOWN_HANDLER_BY_BASE_KEY.put(cmdTypeEnum.getCmdType(), (IJsonCmdDownHandler) bean);
+            log.info("加载 JSON 命令下行处理器 downKey={}, baseKey={}, handler={}",
+                    cmdTypeEnum.getCmdDownType(), cmdTypeEnum.getCmdType(), bean.getClass().getSimpleName());
+        }
+    }
+
+    /**
+     * 按 upKey 获取上行处理器
+     */
+    public IJsonCmdUpHandler getUpHandlerByUpKey(String upKey)
+    {
+        return UP_HANDLER_BY_UP_KEY.get(upKey);
+    }
+
+    /**
+     * 按 baseKey 获取上行处理器
+     */
+    public IJsonCmdUpHandler getUpHandlerByBaseKey(String baseKey)
+    {
+        return UP_HANDLER_BY_BASE_KEY.get(baseKey);
+    }
+
+    /**
+     * 按 downKey 获取下行处理器
+     */
+    public IJsonCmdDownHandler getDownHandlerByDownKey(String downKey)
+    {
+        return DOWN_HANDLER_BY_DOWN_KEY.get(downKey);
+    }
+
+    /**
+     * 按 baseKey 获取下行处理器
+     */
+    public IJsonCmdDownHandler getDownHandlerByBaseKey(String baseKey)
+    {
+        return DOWN_HANDLER_BY_BASE_KEY.get(baseKey);
+    }
+}

+ 2 - 2
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/IJsonCmdHandler.java → ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/IJsonCmdUpHandler.java

@@ -6,11 +6,11 @@ import com.ruoyi.device.mqtt.vo.CommonHeader;
 import com.ruoyi.device.mqtt.vo.CommonTopic;
 
 /**
- * JSON Body 按 cmdType 分发的业务处理器
+ * JSON Body 按 cmdUpType 分发的业务处理器
  *
  * @author lwm
  */
-public interface IJsonCmdHandler
+public interface IJsonCmdUpHandler
 {
     /**
      * @param topic     上行 topic 解析结果

+ 3 - 2
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/JsonBodyDecoder.java

@@ -7,6 +7,7 @@ import com.ruoyi.device.mqtt.annotation.ConsumerHandler;
 import com.ruoyi.device.mqtt.domain.BaseJsonBody;
 import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
 import com.ruoyi.device.mqtt.enums.MsgTypeEnum;
+import com.ruoyi.device.mqtt.handler.JsonCmdHandlerManager;
 import com.ruoyi.device.mqtt.handler.decoder.AbstractDecoder;
 import com.ruoyi.device.mqtt.vo.CommonHeader;
 import com.ruoyi.device.mqtt.vo.CommonTopic;
@@ -81,10 +82,10 @@ public class JsonBodyDecoder extends AbstractDecoder<BaseJsonBody>
         }
 
         // 4、进行业务处理
-        IJsonCmdHandler handler = jsonCmdHandlerManager.getHandlerByUpKey(cmd.getCmdUpType());
+        IJsonCmdUpHandler handler = jsonCmdHandlerManager.getUpHandlerByUpKey(cmd.getCmdUpType());
         if (handler == null)
         {
-            handler = jsonCmdHandlerManager.getHandlerByBaseKey(cmd.getCmdType());
+            handler = jsonCmdHandlerManager.getUpHandlerByBaseKey(cmd.getCmdType());
         }
         if (handler == null)
         {

+ 0 - 65
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/JsonCmdHandlerManager.java

@@ -1,65 +0,0 @@
-package com.ruoyi.device.mqtt.handler.decoder.json;
-
-import com.ruoyi.device.mqtt.annotation.JsonCmdHandler;
-import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.context.ApplicationContext;
-import org.springframework.context.ApplicationListener;
-import org.springframework.context.event.ContextRefreshedEvent;
-import org.springframework.stereotype.Component;
-
-import java.util.HashMap;
-import java.util.Map;
-
-/**
- * 按 {@link CmdTypeEnum} 将 JSON 上行分发给各 {@link IJsonCmdHandler}
- *
- * @author lwm
- */
-@Component
-public class JsonCmdHandlerManager implements ApplicationListener<ContextRefreshedEvent>
-{
-    private static final Logger log = LoggerFactory.getLogger(JsonCmdHandlerManager.class);
-
-    private final Map<String, IJsonCmdHandler> HANDLER_BY_UP_KEY = new HashMap<>();
-    private final Map<String, IJsonCmdHandler> HANDLER_BY_BASE_KEY = new HashMap<>();
-
-    /**
-     * 监听spring容器启动完成,将所有JsonCmdHandler缓存起来
-     *
-     * @param event 上下文
-     */
-    @Override
-    public void onApplicationEvent(ContextRefreshedEvent event)
-    {
-        ApplicationContext ctx = event.getApplicationContext();
-
-        Map<String, Object> jsonCmdHandler = ctx.getBeansWithAnnotation(JsonCmdHandler.class);
-        for (Object bean : jsonCmdHandler.values())
-        {
-            JsonCmdHandler annotation = bean.getClass().getAnnotation(JsonCmdHandler.class);
-            CmdTypeEnum cmdTypeEnum = annotation.cmdType();
-            HANDLER_BY_UP_KEY.put(cmdTypeEnum.getCmdUpType(), (IJsonCmdHandler) bean);
-            HANDLER_BY_BASE_KEY.put(cmdTypeEnum.getCmdType(), (IJsonCmdHandler) bean);
-            log.info("加载 JSON 命令处理器 upKey={}, baseKey={}, handler={}",
-                    cmdTypeEnum.getCmdUpType(), cmdTypeEnum.getCmdType(), bean.getClass().getSimpleName());
-        }
-    }
-
-    /**
-     * 按 upKey 获取处理器
-     */
-    public IJsonCmdHandler getHandlerByUpKey(String upKey)
-    {
-        return HANDLER_BY_UP_KEY.get(upKey);
-    }
-
-    /**
-     * 按 baseKey 获取处理器
-     */
-    public IJsonCmdHandler getHandlerByBaseKey(String baseKey)
-    {
-        return HANDLER_BY_BASE_KEY.get(baseKey);
-    }
-}

+ 4 - 4
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/service/DeviceLoginService.java

@@ -7,13 +7,13 @@ import com.ruoyi.common.enums.UserStatus;
 import com.ruoyi.common.utils.SecurityUtils;
 import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.device.domain.model.TsbUserDeviceBind;
-import com.ruoyi.device.mqtt.annotation.JsonCmdHandler;
+import com.ruoyi.device.mqtt.annotation.JsonCmdUpHandler;
 import com.ruoyi.device.mqtt.constants.MqttConstants;
 import com.ruoyi.device.mqtt.domain.BaseJsonBody;
 import com.ruoyi.device.mqtt.domain.decoder.DeviceLoginRequest;
 import com.ruoyi.device.mqtt.domain.encoder.DeviceLoginResponse;
 import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
-import com.ruoyi.device.mqtt.handler.decoder.json.IJsonCmdHandler;
+import com.ruoyi.device.mqtt.handler.decoder.json.IJsonCmdUpHandler;
 import com.ruoyi.device.mqtt.vo.CommonHeader;
 import com.ruoyi.device.mqtt.vo.CommonTopic;
 import com.ruoyi.device.service.ITsbUserDeviceService;
@@ -34,8 +34,8 @@ import java.util.stream.Collectors;
  *
  * @author lwm
  */
-@JsonCmdHandler(cmdType = CmdTypeEnum.TSB_LOGIN)
-public class DeviceLoginService implements IJsonCmdHandler
+@JsonCmdUpHandler(cmdType = CmdTypeEnum.TSB_LOGIN)
+public class DeviceLoginService implements IJsonCmdUpHandler
 {
     private static final Logger log = LoggerFactory.getLogger(DeviceLoginService.class);
 

+ 4 - 4
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/service/DevicePtService.java

@@ -6,13 +6,13 @@ import com.ruoyi.common.utils.DateUtils;
 import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.device.domain.entity.TsbDevice;
 import com.ruoyi.device.mapper.TsbDeviceMapper;
-import com.ruoyi.device.mqtt.annotation.JsonCmdHandler;
+import com.ruoyi.device.mqtt.annotation.JsonCmdUpHandler;
 import com.ruoyi.device.mqtt.constants.MqttConstants;
 import com.ruoyi.device.mqtt.domain.BaseJsonBody;
 import com.ruoyi.device.mqtt.domain.decoder.DevicePtRequest;
 import com.ruoyi.device.mqtt.domain.encoder.DevicePtResponse;
 import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
-import com.ruoyi.device.mqtt.handler.decoder.json.IJsonCmdHandler;
+import com.ruoyi.device.mqtt.handler.decoder.json.IJsonCmdUpHandler;
 import com.ruoyi.device.mqtt.vo.CommonHeader;
 import com.ruoyi.device.mqtt.vo.CommonTopic;
 import jakarta.annotation.Resource;
@@ -24,8 +24,8 @@ import org.slf4j.LoggerFactory;
  *
  * @author lwm
  */
-@JsonCmdHandler(cmdType = CmdTypeEnum.TSB_PT)
-public class DevicePtService implements IJsonCmdHandler
+@JsonCmdUpHandler(cmdType = CmdTypeEnum.TSB_PT)
+public class DevicePtService implements IJsonCmdUpHandler
 {
     private static final Logger log = LoggerFactory.getLogger(DevicePtService.class);
 

+ 26 - 7
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/service/TaxDataUpService.java

@@ -1,36 +1,55 @@
 package com.ruoyi.device.mqtt.handler.decoder.json.service;
 
 import com.alibaba.fastjson2.JSON;
-import com.ruoyi.device.mqtt.annotation.JsonCmdHandler;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.device.mqtt.annotation.JsonCmdUpHandler;
 import com.ruoyi.device.mqtt.domain.BaseJsonBody;
 import com.ruoyi.device.mqtt.domain.decoder.TaxDataUp;
 import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
-import com.ruoyi.device.mqtt.handler.decoder.json.IJsonCmdHandler;
+import com.ruoyi.device.mqtt.handler.decoder.json.IJsonCmdUpHandler;
 import com.ruoyi.device.mqtt.vo.CommonHeader;
 import com.ruoyi.device.mqtt.vo.CommonTopic;
+import com.ruoyi.device.websocket.TsbWebSocketService;
+import jakarta.annotation.Resource;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /**
- * 报税口上行数据
+ * 报税口上行:common:tax:up
+ * <p>
+ * 解析设备页面数据并推送到 WebSocket(无 MQTT 下行应答)。
  *
  * @author lwm
  */
-@JsonCmdHandler(cmdType = CmdTypeEnum.COMMON_TAX)
-public class TaxDataUpService implements IJsonCmdHandler
+@JsonCmdUpHandler(cmdType = CmdTypeEnum.COMMON_TAX)
+public class TaxDataUpService implements IJsonCmdUpHandler
 {
     private static final Logger log = LoggerFactory.getLogger(TaxDataUpService.class);
 
+    @Resource
+    private TsbWebSocketService tsbWebSocketService;
+
     @Override
     public BaseJsonBody handle(CommonTopic topic, CommonHeader header, String bodyJson, CmdTypeEnum cmd)
     {
-        TaxDataUp taxDataUp = JSON.parseObject(bodyJson, TaxDataUp.class);
+        // 1、JSON 反序列化(保留原始 key,避免未传字段被补成 null 推到前端)
+        // 因为 只传几个字段,就改变几个字段:部分字段为null,就清空显示
+        JSONObject bodyObj = JSON.parseObject(bodyJson);
+        if (bodyObj == null)
+        {
+            log.warn("报税口报文体解析失败, deviceSn={}", topic.getDeviceSn());
+            return BaseJsonBody.fail(topic.getDeviceType(), topic.getDeviceSn(), cmd.getCmdDownType(), "报税口报文体解析失败");
+        }
+        TaxDataUp taxDataUp = bodyObj.to(TaxDataUp.class);
         if (taxDataUp == null)
         {
-            log.warn("报税口报文体解析失败");
+            log.warn("报税口报文体解析失败, deviceSn={}", topic.getDeviceSn());
             return BaseJsonBody.fail(topic.getDeviceType(), topic.getDeviceSn(), cmd.getCmdDownType(), "报税口报文体解析失败");
         }
 
+        // 2、推送 Web 端
+        tsbWebSocketService.pushPageSyncFromDevice(cmd.getCmdType(), bodyObj);
+        // 3、无需下行应答
         return null;
     }
 }

+ 21 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/encoder/json/IJsonCmdDownHandler.java

@@ -0,0 +1,21 @@
+package com.ruoyi.device.mqtt.handler.encoder.json;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.device.domain.model.TsbUserDeviceBind;
+import com.ruoyi.device.mqtt.domain.BaseJsonBody;
+import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
+
+/**
+ * JSON Body 按 cmdDownType 分发的业务处理器
+ *
+ * @author lwm
+ */
+public interface IJsonCmdDownHandler
+{
+    /**
+     * @param bind      绑定信息
+     * @param data      业务数据
+     * @param cmd       已匹配的下行命令枚举
+     */
+    BaseJsonBody handle(TsbUserDeviceBind bind, JSONObject data, CmdTypeEnum cmd);
+}

+ 39 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/encoder/json/service/TaxDataDownService.java

@@ -0,0 +1,39 @@
+package com.ruoyi.device.mqtt.handler.encoder.json.service;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.device.domain.model.TsbUserDeviceBind;
+import com.ruoyi.device.mqtt.annotation.JsonCmdDownHandler;
+import com.ruoyi.device.mqtt.domain.BaseJsonBody;
+import com.ruoyi.device.mqtt.domain.encoder.TaxDataDown;
+import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
+import com.ruoyi.device.mqtt.handler.encoder.json.IJsonCmdDownHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * 报税口下行:common:tax:down
+ * <p>
+ * 收到 WebSocket页面数据并推送到 设备 MQTT 下行应答
+ *
+ * @author lwm
+ */
+@JsonCmdDownHandler(cmdType = CmdTypeEnum.COMMON_TAX)
+public class TaxDataDownService implements IJsonCmdDownHandler
+{
+    private static final Logger log = LoggerFactory.getLogger(TaxDataDownService.class);
+
+    @Override
+    public BaseJsonBody handle(TsbUserDeviceBind bind, JSONObject data, CmdTypeEnum cmd)
+    {
+        TaxDataDown taxDataDown = data.to(TaxDataDown.class);
+        if (taxDataDown == null)
+        {
+            log.warn("报税口报文体解析失败, deviceSn={}", bind.getDeviceSn());
+            return null;
+        }
+        taxDataDown.setDeviceType(bind.getDeviceType());
+        taxDataDown.setDeviceSn(bind.getDeviceSn());
+        taxDataDown.setCmdType(cmd.getCmdDownType());
+        return taxDataDown;
+    }
+}

+ 20 - 8
ruoyi-device/src/main/java/com/ruoyi/device/websocket/TsbWebSocketService.java

@@ -7,12 +7,13 @@ import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.device.domain.model.TsbUserDeviceBind;
 import com.ruoyi.device.mapper.TsbUserDeviceMapper;
 import com.ruoyi.device.mqtt.domain.BaseJsonBody;
-import com.ruoyi.device.mqtt.domain.encoder.TaxDataDown;
 import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
 import com.ruoyi.device.mqtt.enums.MsgTypeEnum;
 import com.ruoyi.device.mqtt.handler.DeviceOnlineManager;
 import com.ruoyi.device.mqtt.handler.HandlerManager;
+import com.ruoyi.device.mqtt.handler.JsonCmdHandlerManager;
 import com.ruoyi.device.mqtt.handler.encoder.IEncoder;
+import com.ruoyi.device.mqtt.handler.encoder.json.IJsonCmdDownHandler;
 import com.ruoyi.device.mqtt.util.MsgHandlerUtil;
 import com.ruoyi.device.websocket.model.TsbWebSocketMessage;
 import com.ruoyi.framework.web.service.TokenService;
@@ -45,6 +46,9 @@ public class TsbWebSocketService
     private HandlerManager handlerManager;
 
     @Resource
+    private JsonCmdHandlerManager jsonCmdHandlerManager;
+
+    @Resource
     private DeviceOnlineManager deviceOnlineManager;
 
     /**
@@ -126,12 +130,20 @@ public class TsbWebSocketService
             return;
         }
 
-        // 3、发送消息
-        TaxDataDown down = tsbWebSocketMessage.getData() == null ?
-                new TaxDataDown() : tsbWebSocketMessage.getData().toJavaObject(TaxDataDown.class);
-        down.setDeviceType(bind.getDeviceType());
-        down.setDeviceSn(bind.getDeviceSn());
-        down.setCmdType(cmdType.getCmdDownType());
+        // 3、Web data → MQTT 下行体(保留 JSONObject 全部字段)
+        IJsonCmdDownHandler handler = jsonCmdHandlerManager.getDownHandlerByDownKey(cmdType.getCmdDownType());
+        if (handler == null)
+        {
+            handler = jsonCmdHandlerManager.getDownHandlerByBaseKey(cmdType.getCmdType());
+        }
+        if (handler == null)
+        {
+            log.warn("未注册 JSON 命令处理器 cmd={}", cmdType);
+            TsbWebSocketUsers.sendMessageToUserByText(session,
+                    TsbWebSocketMessage.fail(tsbWebSocketMessage.getCmdType(), "未实现的服务处理器"));
+            return;
+        }
+        BaseJsonBody baseJsonBody = handler.handle(bind, tsbWebSocketMessage.getData(), cmdType);
         log.info("Web端 -> 设备 MQTT 同步消息, userId={}, deviceSn={}", bind.getUserId(), bind.getDeviceSn());
         String key = MsgHandlerUtil.getEncoderKey(MsgTypeEnum.JSON_BODY);
         IEncoder<BaseJsonBody> encoder = (IEncoder<BaseJsonBody>) handlerManager.getEncoder(key);
@@ -142,7 +154,7 @@ public class TsbWebSocketService
         }
         log.info("MQTT 下行发送, cmdType={}, deviceType={}, deviceSn={}",
                 cmdType.getCmdType(), bind.getDeviceType(), bind.getDeviceSn());
-        encoder.encode(down);
+        encoder.encode(baseJsonBody);
     }
 
     /**

+ 25 - 3
ruoyi-device/src/test/java/com/ruoyi/device/EncoderDecoderTest.java

@@ -4,6 +4,8 @@ import com.alibaba.fastjson2.JSON;
 import com.ruoyi.device.mqtt.constants.MqttConstants;
 import com.ruoyi.device.mqtt.domain.decoder.DeviceLoginRequest;
 import com.ruoyi.device.mqtt.domain.decoder.DevicePtRequest;
+import com.ruoyi.device.mqtt.domain.decoder.TaxDataUp;
+import com.ruoyi.device.mqtt.domain.encoder.TaxDataDown;
 import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
 import com.ruoyi.device.mqtt.enums.VersionEnum;
 import com.ruoyi.device.mqtt.util.CRC16Standard;
@@ -46,7 +48,7 @@ public class EncoderDecoderTest
     public void encoder()
     {
         completeMessageStructure();
-        String jsonString = JSON.toJSONString(buildDevicePtRequest());
+        String jsonString = JSON.toJSONString(buildTaxDataUp());
         System.out.println("【上行组帧】JSON序列化结果:");
         System.out.println(jsonString);
         // 模拟JSON字节数组构建(与AbstractJsonEncoder逻辑一致)
@@ -81,12 +83,27 @@ public class EncoderDecoderTest
         return request;
     }
 
+    public TaxDataUp buildTaxDataUp()
+    {
+        TaxDataUp up = new TaxDataUp();
+        up.setCmdType(CmdTypeEnum.COMMON_TAX.getCmdUpType());
+        up.setDeviceType("9102");
+        up.setDeviceSn(5L);
+
+        // 非必须字段(按照图片中的 mock 值填充)
+        up.setInterfaceNo("B");                                     // mock: A - 接口号
+        up.setGunNo(2);                                             // mock: 1 - 枪号
+        up.setNewNationalStandard(0);                               // mock: 0 - 是否开启新国标,1:开启,0:关闭
+
+        return up;
+    }
+
     @Test
     public void decoder()
     {
         completeMessageStructure();
-        System.out.println("【下行解析】完整报文:" + getDeviceLoginResponse());
-        byte[] bytes = hexStringToByte(getDeviceLoginResponse().replace(" ", ""));
+        System.out.println("【下行解析】完整报文:" + getTaxDataDown());
+        byte[] bytes = hexStringToByte(getTaxDataDown().replace(" ", ""));
         String decode = decode(bytes);
         System.out.println("【下行解析】报文体:" + decode);
     }
@@ -103,6 +120,11 @@ public class EncoderDecoderTest
         return "fefe 0301 0000 004a 0000 d200 7b22 636d 6454 7970 6522 3a22 7473 623a 7074 3a64 6f77 6e22 2c22 636f 6465 223a 312c 2264 6576 6963 6543 6f6e 6669 6722 3a7b 2264 6576 6963 6550 726f 6475 6365 4461 7465 223a 2232 3032 3630 3531 3322 2c22 6465 7669 6365 536e 223a 352c 2264 6576 6963 6554 7970 6522 3a22 3931 3032 227d 2c22 6465 7669 6365 536e 223a 3836 3437 3933 3038 3030 3835 3630 332c 2264 6576 6963 6554 7970 6522 3a22 3931 3032 222c 2269 6d65 6922 3a22 3836 3437 3933 3038 3030 3835 3630 3322 2c22 6d73 6722 3a22 e693 8de4 bd9c e688 90e5 8a9f 227d 22f2";
     }
 
+    public String getTaxDataDown()
+    {
+        // {"buttonType":"common:tax:query","cmdType":"common:tax:down","deviceSn":5,"deviceType":"9102","gunNo":1,"interfaceNo":"A","newNationalStandard":0,"newNationalStandardTaxNo":1,"queryDate":"2026-05-25"}
+        return "fefe 0301 0000 004a 0000 ca00 7b22 6275 7474 6f6e 5479 7065 223a 2263 6f6d 6d6f 6e3a 7461 783a 7175 6572 7922 2c22 636d 6454 7970 6522 3a22 636f 6d6d 6f6e 3a74 6178 3a64 6f77 6e22 2c22 6465 7669 6365 536e 223a 352c 2264 6576 6963 6554 7970 6522 3a22 3931 3032 222c 2267 756e 4e6f 223a 312c 2269 6e74 6572 6661 6365 4e6f 223a 2241 222c 226e 6577 4e61 7469 6f6e 616c 5374 616e 6461 7264 223a 302c 226e 6577 4e61 7469 6f6e 616c 5374 616e 6461 7264 5461 784e 6f22 3a31 2c22 7175 6572 7944 6174 6522 3a22 3230 3236 2d30 352d 3235 227d a57e";
+    }
 
     /**
      * 上行组帧测试(设备 → 平台)

+ 9 - 0
ruoyi-ui/src/api/tsb/ws.js

@@ -0,0 +1,9 @@
+import request from '@/utils/request'
+
+/** 查询当前用户设备绑定(WebSocket 前置校验) */
+export function getTsbWsBind() {
+  return request({
+    url: '/tsb/ws/bind',
+    method: 'get'
+  })
+}

+ 7 - 0
ruoyi-ui/src/permission.js

@@ -1,5 +1,6 @@
 import router from './router'
 import store from './store'
+import tsbWebSocket from '@/utils/tsbWebSocket'
 import { Message } from 'element-ui'
 import NProgress from 'nprogress'
 import 'nprogress/nprogress.css'
@@ -11,6 +12,10 @@ NProgress.configure({ showSpinner: false })
 
 const whiteList = ['/login', '/register']
 
+function connectTsbWebSocket() {
+  tsbWebSocket.connect()
+}
+
 const isWhiteList = (path) => {
   return whiteList.some(pattern => isPathMatch(pattern, path))
 }
@@ -38,6 +43,7 @@ router.beforeEach((to, from, next) => {
         // 判断当前用户是否已拉取完user_info信息
         store.dispatch('GetInfo').then(() => {
           isRelogin.show = false
+          connectTsbWebSocket()
           store.dispatch('GenerateRoutes').then(accessRoutes => {
             // 根据roles权限生成可访问的路由表
             router.addRoutes(accessRoutes) // 动态添加可访问路由表
@@ -50,6 +56,7 @@ router.beforeEach((to, from, next) => {
             })
           })
       } else {
+        connectTsbWebSocket()
         next()
       }
     }

+ 3 - 1
ruoyi-ui/src/store/index.js

@@ -7,6 +7,7 @@ import user from './modules/user'
 import tagsView from './modules/tagsView'
 import permission from './modules/permission'
 import settings from './modules/settings'
+import tsb from './modules/tsb'
 import getters from './getters'
 
 Vue.use(Vuex)
@@ -19,7 +20,8 @@ const store = new Vuex.Store({
     user,
     tagsView,
     permission,
-    settings
+    settings,
+    tsb
   },
   getters
 })

+ 32 - 0
ruoyi-ui/src/store/modules/tsb.js

@@ -0,0 +1,32 @@
+const tsb = {
+  namespaced: true,
+  state: {
+    /** 各 cmdType 最新推送数据 */
+    pageData: {},
+    /** WebSocket 跳转时标记,页面初始化后清除 */
+    initFromWs: {},
+    /** 数据版本号,供页面 watch 实时更新 */
+    pageDataVersion: 0
+  },
+  mutations: {
+    SET_WS_PAGE_DATA(state, { cmdType, data, navigate }) {
+      state.pageData = { ...state.pageData, [cmdType]: data }
+      state.pageDataVersion += 1
+      if (navigate) {
+        state.initFromWs = { ...state.initFromWs, [cmdType]: true }
+      }
+    },
+    CLEAR_WS_INIT(state, cmdType) {
+      const initFromWs = { ...state.initFromWs }
+      delete initFromWs[cmdType]
+      state.initFromWs = initFromWs
+    },
+    RESET_TSB(state) {
+      state.pageData = {}
+      state.initFromWs = {}
+      state.pageDataVersion = 0
+    }
+  }
+}
+
+export default tsb

+ 4 - 0
ruoyi-ui/src/store/modules/user.js

@@ -4,6 +4,7 @@ import cache from '@/plugins/cache'
 import { MessageBox, } from 'element-ui'
 import { login, logout, getInfo } from '@/api/login'
 import { getToken, setToken, removeToken } from '@/utils/auth'
+import tsbWebSocket from '@/utils/tsbWebSocket'
 import { isHttp, isEmpty } from "@/utils/validate"
 import defAva from '@/assets/images/profile.jpg'
 
@@ -54,6 +55,7 @@ const user = {
           setToken(res.token)
           commit('SET_TOKEN', res.token)
           store.dispatch('lock/unlockScreen')
+          tsbWebSocket.connectAfterLogin()
           resolve()
         }).catch(error => {
           reject(error)
@@ -103,6 +105,8 @@ const user = {
     // 退出系统
     LogOut({ commit, state }) {
       return new Promise((resolve, reject) => {
+        tsbWebSocket.disconnect(true)
+        commit('tsb/RESET_TSB', null, { root: true })
         logout(state.token).then(() => {
           commit('SET_TOKEN', '')
           commit('SET_ROLES', [])

+ 13 - 0
ruoyi-ui/src/utils/tsbCmdRoute.js

@@ -0,0 +1,13 @@
+/**
+ * cmdType 与前端路由映射(设备推送页面跳转)
+ */
+export const TSB_CMD_ROUTE_MAP = {
+  'common:tax': '/app-common/tax'
+}
+
+/**
+ * 根据 cmdType 获取路由路径
+ */
+export function getRouteByCmdType(cmdType) {
+  return TSB_CMD_ROUTE_MAP[cmdType] || null
+}

+ 290 - 0
ruoyi-ui/src/utils/tsbWebSocket.js

@@ -0,0 +1,290 @@
+import { getToken } from '@/utils/auth'
+import { Message } from 'element-ui'
+import { initTsbWsRouter } from '@/utils/tsbWsRouter'
+import {
+  initTabCoordinator,
+  isLeaderTab,
+  broadcastWsMessage,
+  broadcastWsStatus,
+  requestSend,
+  requestLeaderReconnect,
+  notifyLogout,
+  destroyTabCoordinator,
+  resetLeadershipForLogin,
+  reclaimLeadershipIfStale
+} from '@/utils/tsbWebSocketTab'
+
+let socket = null
+let reconnectTimer = null
+let manualClose = false
+let messageHandler = null
+/** 连接代次,用于忽略旧 socket 的 close/error 回调 */
+let connectId = 0
+/** 自动重连次数 */
+let reconnectAttempts = 0
+let reconnectExhaustedNotified = false
+/** 非 Leader 标签页感知的连接状态 */
+let followerConnectedState = false
+let tabCoordinatorInited = false
+let wsRouterInited = false
+const RECONNECT_DELAY = 3000
+const MAX_RECONNECT_ATTEMPTS = 3
+
+function ensureWsRouter() {
+  if (wsRouterInited) {
+    return
+  }
+  wsRouterInited = true
+  initTsbWsRouter((handler) => onMessage(handler))
+}
+
+function ensureTabCoordinator() {
+  if (tabCoordinatorInited) {
+    return
+  }
+  tabCoordinatorInited = true
+  initTabCoordinator({
+    onBecomeLeader: (forceReconnect) => {
+      if (forceReconnect) {
+        leaderDisconnectSilently()
+        resetReconnectAttempts()
+        leaderConnect(false)
+        return
+      }
+      leaderConnect(true)
+    },
+    onBecomeFollower: () => leaderDisconnectSilently(),
+    onBroadcastMessage: (message) => messageHandler?.(message),
+    onFollowerSend: (payload) => leaderSend(payload.cmdType, payload.data),
+    onLeaderStatus: (connected) => {
+      followerConnectedState = connected
+    },
+    onStatusRequest: () => {
+      const connected = socket && socket.readyState === WebSocket.OPEN
+      broadcastWsStatus(connected)
+    }
+  })
+}
+
+function resetReconnectAttempts() {
+  reconnectAttempts = 0
+  reconnectExhaustedNotified = false
+}
+
+function buildWsUrl(token) {
+  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+  const base = process.env.VUE_APP_BASE_API || ''
+  return `${protocol}//${window.location.host}${base}/websocket/tsb?token=${encodeURIComponent(token)}`
+}
+
+function clearReconnectTimer() {
+  if (reconnectTimer) {
+    clearTimeout(reconnectTimer)
+    reconnectTimer = null
+  }
+}
+
+function scheduleReconnect() {
+  if (!isLeaderTab() || manualClose || !getToken()) {
+    return
+  }
+  if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
+    if (!reconnectExhaustedNotified) {
+      reconnectExhaustedNotified = true
+      console.warn(`TSB WebSocket 自动重连已达上限(${MAX_RECONNECT_ATTEMPTS} 次),请手动重连`)
+      Message.warning(`WebSocket 自动重连已达上限(${MAX_RECONNECT_ATTEMPTS} 次),请手动重连`)
+    }
+    broadcastWsStatus(false)
+    return
+  }
+  reconnectAttempts++
+  clearReconnectTimer()
+  reconnectTimer = setTimeout(() => leaderConnect(false), RECONNECT_DELAY)
+}
+
+function bindSocketEvents(ws, id) {
+  ws.onopen = () => {
+    if (id !== connectId) {
+      return
+    }
+    clearReconnectTimer()
+    broadcastWsStatus(true)
+  }
+  ws.onmessage = (evt) => {
+    if (id !== connectId || !messageHandler) {
+      return
+    }
+    try {
+      const message = JSON.parse(evt.data)
+      messageHandler(message)
+      broadcastWsMessage(message)
+    } catch (e) {
+      console.warn('TSB WebSocket 消息解析失败', e)
+    }
+  }
+  ws.onclose = () => {
+    if (id !== connectId) {
+      return
+    }
+    socket = null
+    broadcastWsStatus(false)
+    if (!manualClose && isLeaderTab() && getToken()) {
+      scheduleReconnect()
+    }
+  }
+  ws.onerror = () => {
+    if (id !== connectId) {
+      return
+    }
+    ws.close()
+  }
+}
+
+function leaderConnect(resetAttempts = true) {
+  ensureTabCoordinator()
+  if (!isLeaderTab()) {
+    return
+  }
+  const token = getToken()
+  if (!token || manualClose) {
+    return
+  }
+  if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
+    return
+  }
+  clearReconnectTimer()
+  if (resetAttempts !== false) {
+    resetReconnectAttempts()
+  }
+  manualClose = false
+  const id = ++connectId
+  socket = new WebSocket(buildWsUrl(token))
+  bindSocketEvents(socket, id)
+}
+
+function leaderDisconnectSilently() {
+  manualClose = true
+  clearReconnectTimer()
+  connectId++
+  if (socket) {
+    socket.close()
+    socket = null
+  }
+  manualClose = false
+  broadcastWsStatus(false)
+}
+
+function leaderSend(cmdType, data) {
+  if (!socket || socket.readyState !== WebSocket.OPEN) {
+    return false
+  }
+  socket.send(JSON.stringify({
+    cmdType,
+    data: data || {}
+  }))
+  return true
+}
+
+function connect(resetAttempts = true, options = {}) {
+  ensureWsRouter()
+  ensureTabCoordinator()
+  if (options.forceLeader) {
+    resetLeadershipForLogin()
+  } else {
+    reclaimLeadershipIfStale()
+  }
+  if (!isLeaderTab()) {
+    return
+  }
+  leaderConnect(resetAttempts)
+}
+
+/** 登录成功后立即尝试建立 WebSocket(新登录抢占 leader,避免多标签页残留锁) */
+function connectAfterLogin() {
+  connect(true, { forceLeader: true })
+}
+
+function disconnect(logout) {
+  ensureTabCoordinator()
+  manualClose = true
+  clearReconnectTimer()
+  resetReconnectAttempts()
+  connectId++
+  if (socket) {
+    socket.close()
+    socket = null
+  }
+  followerConnectedState = false
+  broadcastWsStatus(false)
+  if (logout) {
+    notifyLogout()
+    destroyTabCoordinator()
+    tabCoordinatorInited = false
+    wsRouterInited = false
+  } else if (!logout) {
+    manualClose = false
+  }
+}
+
+function reconnect() {
+  ensureTabCoordinator()
+  clearReconnectTimer()
+  resetReconnectAttempts()
+  requestLeaderReconnect()
+}
+
+function isReconnectExhausted() {
+  return reconnectAttempts >= MAX_RECONNECT_ATTEMPTS
+}
+
+function notifyConnectSuccess() {
+  resetReconnectAttempts()
+  broadcastWsStatus(true)
+}
+
+function notifyConnectFailed() {
+  // 鉴权失败等场景由 onclose 触发 scheduleReconnect
+}
+
+function send(cmdType, data) {
+  ensureTabCoordinator()
+  if (isLeaderTab()) {
+    return leaderSend(cmdType, data)
+  }
+  requestSend({ cmdType, data })
+  return true
+}
+
+function sendPageSync(cmdType, data) {
+  return send(cmdType, data)
+}
+
+function onMessage(handler) {
+  messageHandler = handler
+}
+
+function isConnected() {
+  if (isLeaderTab()) {
+    return socket && socket.readyState === WebSocket.OPEN
+  }
+  return followerConnectedState
+}
+
+function getReconnectAttempts() {
+  return reconnectAttempts
+}
+
+export default {
+  connect,
+  connectAfterLogin,
+  disconnect,
+  reconnect,
+  send,
+  sendPageSync,
+  onMessage,
+  isConnected,
+  isReconnectExhausted,
+  getReconnectAttempts,
+  notifyConnectSuccess,
+  notifyConnectFailed
+}

+ 234 - 0
ruoyi-ui/src/utils/tsbWebSocketTab.js

@@ -0,0 +1,234 @@
+/**
+ * 多标签页 WebSocket 协调:同一用户仅一个标签页持有连接
+ */
+const TAB_ID = `${Date.now()}-${Math.random()}`
+const CHANNEL_NAME = 'tsb-ws-tab'
+const LEADER_STORAGE_KEY = 'tsb-ws-leader'
+const LEADER_TTL = 5000
+const HEARTBEAT_INTERVAL = 2000
+
+let channel = null
+let heartbeatTimer = null
+let isLeader = false
+let followerConnected = false
+
+const handlers = {
+  onBecomeLeader: null,
+  onBecomeFollower: null,
+  onBroadcastMessage: null,
+  onFollowerSend: null,
+  onLeaderStatus: null,
+  onStatusRequest: null
+}
+
+function readLeader() {
+  try {
+    const raw = localStorage.getItem(LEADER_STORAGE_KEY)
+    return raw ? JSON.parse(raw) : null
+  } catch (e) {
+    return null
+  }
+}
+
+function writeLeader() {
+  localStorage.setItem(LEADER_STORAGE_KEY, JSON.stringify({
+    id: TAB_ID,
+    ts: Date.now()
+  }))
+}
+
+function clearLeaderIfMine() {
+  const leader = readLeader()
+  if (leader && leader.id === TAB_ID) {
+    localStorage.removeItem(LEADER_STORAGE_KEY)
+  }
+}
+
+function postChannel(type, payload) {
+  if (!channel) {
+    return
+  }
+  channel.postMessage({ type, payload, from: TAB_ID })
+}
+
+function becomeLeader() {
+  if (isLeader) {
+    return
+  }
+  isLeader = true
+  followerConnected = false
+  handlers.onBecomeLeader?.()
+}
+
+function becomeFollower() {
+  const wasLeader = isLeader
+  isLeader = false
+  if (wasLeader) {
+    handlers.onBecomeFollower?.()
+  }
+  postChannel('ws-status-request', {})
+}
+
+function isLeaderFresh(leader) {
+  return leader && (Date.now() - leader.ts < LEADER_TTL)
+}
+
+function tryClaimLeadership() {
+  const leader = readLeader()
+  if (!isLeaderFresh(leader) || leader.id === TAB_ID) {
+    writeLeader()
+    becomeLeader()
+    return true
+  }
+  if (leader.id !== TAB_ID) {
+    becomeFollower()
+    return false
+  }
+  return isLeader
+}
+
+function maintainLeadership() {
+  if (isLeader) {
+    writeLeader()
+    return
+  }
+  tryClaimLeadership()
+}
+
+function onStorageChange(e) {
+  if (e.key !== LEADER_STORAGE_KEY) {
+    return
+  }
+  const leader = readLeader()
+  if (isLeader && leader && leader.id !== TAB_ID) {
+    becomeFollower()
+    return
+  }
+  if (!isLeader && !isLeaderFresh(leader)) {
+    tryClaimLeadership()
+  }
+}
+
+function onChannelMessage(evt) {
+  const { type, payload, from } = evt.data || {}
+  if (from === TAB_ID) {
+    return
+  }
+  if (type === 'ws-message') {
+    handlers.onBroadcastMessage?.(payload)
+  } else if (type === 'ws-send' && isLeader) {
+    handlers.onFollowerSend?.(payload)
+  } else if (type === 'ws-status') {
+    if (!isLeader) {
+      followerConnected = !!payload?.connected
+      handlers.onLeaderStatus?.(followerConnected)
+    }
+  } else if (type === 'ws-status-request' && isLeader) {
+    handlers.onStatusRequest?.()
+  } else if (type === 'ws-logout') {
+    isLeader = false
+    followerConnected = false
+    handlers.onBecomeFollower?.()
+  } else if (type === 'ws-reconnect' && isLeader) {
+    handlers.onBecomeLeader?.(true)
+  }
+}
+
+function releaseLeadership() {
+  clearLeaderIfMine()
+}
+
+function notifyTabLogout() {
+  clearLeaderIfMine()
+  postChannel('ws-logout', {})
+}
+
+export function resetLeadershipForLogin() {
+  localStorage.removeItem(LEADER_STORAGE_KEY)
+  if (channel) {
+    tryClaimLeadership()
+  }
+}
+
+/** Leader 心跳过期时重新抢占(避免关闭标签页后残留 leader 导致无法建连) */
+export function reclaimLeadershipIfStale() {
+  const leader = readLeader()
+  if (!isLeaderFresh(leader)) {
+    localStorage.removeItem(LEADER_STORAGE_KEY)
+    tryClaimLeadership()
+  }
+}
+
+export function initTabCoordinator(options = {}) {
+  Object.assign(handlers, options)
+
+  if (typeof BroadcastChannel === 'undefined') {
+    becomeLeader()
+    return
+  }
+
+  channel = new BroadcastChannel(CHANNEL_NAME)
+  channel.onmessage = onChannelMessage
+  window.addEventListener('storage', onStorageChange)
+  window.addEventListener('beforeunload', releaseLeadership)
+  window.addEventListener('pagehide', releaseLeadership)
+
+  tryClaimLeadership()
+  if (!isLeader) {
+    postChannel('ws-status-request', {})
+  }
+  if (heartbeatTimer) {
+    clearInterval(heartbeatTimer)
+  }
+  heartbeatTimer = setInterval(maintainLeadership, HEARTBEAT_INTERVAL)
+}
+
+export function isLeaderTab() {
+  return isLeader
+}
+
+export function isFollowerConnected() {
+  return followerConnected
+}
+
+export function broadcastWsMessage(message) {
+  postChannel('ws-message', message)
+}
+
+export function broadcastWsStatus(connected) {
+  if (!isLeader) {
+    return
+  }
+  postChannel('ws-status', { connected })
+}
+
+export function requestSend(payload) {
+  postChannel('ws-send', payload)
+}
+
+export function requestLeaderReconnect() {
+  tryClaimLeadership()
+  postChannel('ws-reconnect', {})
+  if (isLeader) {
+    handlers.onBecomeLeader?.(true)
+  }
+}
+
+export function notifyLogout() {
+  notifyTabLogout()
+}
+
+export function destroyTabCoordinator() {
+  if (heartbeatTimer) {
+    clearInterval(heartbeatTimer)
+    heartbeatTimer = null
+  }
+  releaseLeadership()
+  if (channel) {
+    channel.close()
+    channel = null
+  }
+  window.removeEventListener('storage', onStorageChange)
+  window.removeEventListener('beforeunload', releaseLeadership)
+  window.removeEventListener('pagehide', releaseLeadership)
+}

+ 71 - 0
ruoyi-ui/src/utils/tsbWsRouter.js

@@ -0,0 +1,71 @@
+import store from '@/store'
+import router from '@/router'
+import { Message } from 'element-ui'
+import { getRouteByCmdType } from '@/utils/tsbCmdRoute'
+import tsbWebSocket from '@/utils/tsbWebSocket'
+
+/**
+ * 处理 WebSocket 推送:按 cmdType 跳转页面并缓存 data 供页面初始化
+ */
+export function handleTsbWsMessage(msg) {
+  if (!msg || !msg.cmdType) {
+    return
+  }
+
+  if (msg.cmdType === 'connect') {
+    if (Number(msg.code) === 1) {
+      tsbWebSocket.notifyConnectSuccess()
+    } else {
+      tsbWebSocket.notifyConnectFailed()
+      if (msg.message) {
+        Message.error(msg.message || 'WebSocket 连接失败')
+      }
+    }
+    return
+  }
+
+  const routePath = getRouteByCmdType(msg.cmdType)
+  if (!routePath) {
+    return
+  }
+
+  if (Number(msg.code) !== 1) {
+    Message.error(msg.message || '操作失败')
+    return
+  }
+
+  if (!msg.data) {
+    return
+  }
+
+  const data = typeof msg.data === 'string'
+    ? (() => {
+      try {
+        return JSON.parse(msg.data)
+      } catch (e) {
+        return null
+      }
+    })()
+    : msg.data
+  if (!data) {
+    return
+  }
+
+  const onTargetPage = router.currentRoute.path === routePath
+  store.commit('tsb/SET_WS_PAGE_DATA', {
+    cmdType: msg.cmdType,
+    data,
+    navigate: !onTargetPage
+  })
+
+  if (!onTargetPage) {
+    router.push(routePath).catch(() => {})
+  }
+}
+
+/**
+ * 注册全局 WebSocket 消息路由(登录后调用一次)
+ */
+export function initTsbWsRouter(onMessage) {
+  onMessage(handleTsbWsMessage)
+}

+ 389 - 0
ruoyi-ui/src/views/app-common/tax/index.vue

@@ -0,0 +1,389 @@
+<template>
+  <div class="app-container tax-page">
+    <el-alert
+      v-if="!bindInfo.deviceSn"
+      title="当前用户未绑定调试宝设备,无法同步页面数据"
+      type="warning"
+      show-icon
+      :closable="false"
+      class="mb16"
+    />
+    <el-row v-else type="flex" justify="space-between" align="middle" class="mb16">
+      <el-col :span="16">
+        <el-tag :type="wsConnected ? 'success' : 'info'" size="small">
+          {{ wsConnected ? 'WebSocket 已连接' : 'WebSocket 未连接' }}
+        </el-tag>
+        <span class="bind-text">设备 SN:{{ bindInfo.deviceSn }}</span>
+      </el-col>
+      <el-col :span="8" style="text-align: right">
+        <el-button size="mini" @click="loadBind">刷新绑定</el-button>
+        <el-button size="mini" type="primary" @click="reconnectWs">重连</el-button>
+      </el-col>
+    </el-row>
+
+    <!-- 顶部参数行 -->
+    <el-card shadow="never" class="mb16">
+      <el-row :gutter="12" class="param-row">
+        <el-col :xs="12" :sm="6" :md="4">
+          <div class="field-label">接口号</div>
+          <el-select v-model="form.interfaceNo" size="small" @change="onInterfaceNoChange">
+            <el-option label="A" value="A" />
+            <el-option label="B" value="B" />
+          </el-select>
+        </el-col>
+        <el-col :xs="12" :sm="6" :md="4">
+          <div class="field-label">枪号</div>
+          <el-input-number v-model="form.gunNo" :min="1" :max="9" size="small" controls-position="right" @change="onGunNoChange" />
+        </el-col>
+        <el-col :xs="12" :sm="6" :md="4">
+          <div class="field-label">新国标</div>
+          <el-switch v-model="newStandardOn" active-text="启用" inactive-text="禁用" @change="onNewStandardChange" />
+        </el-col>
+        <el-col :xs="12" :sm="6" :md="4">
+          <div class="field-label">新国标报税口</div>
+          <el-input-number v-model="form.newNationalStandardTaxNo" :min="1" :max="9" size="small" controls-position="right" @change="onNewNationalStandardTaxNoChange" />
+        </el-col>
+      </el-row>
+    </el-card>
+
+    <el-row :gutter="16">
+      <!-- 左侧:税控序列号 -->
+      <el-col :xs="24" :md="10" :lg="8">
+        <el-card shadow="hover" class="panel-card">
+          <div slot="header" class="panel-title">税控序列号查询</div>
+            <el-button type="primary" size="small" class="mb16" @click="queryTaxSerial">查询税控序列号</el-button>
+          <div class="result-item"><span class="label">税控序列号:</span>{{ form.taxNo || '-' }}</div>
+          <div class="result-item"><span class="label">厂家:</span>{{ form.manufacturer || '-' }}</div>
+          <div class="result-item"><span class="label">枪个数:</span>{{ form.gunNumber != null ? form.gunNumber : '-' }}</div>
+          <div class="result-item"><span class="label">执行结果:</span>
+            <el-tag size="mini" :type="resultTag(form.queryTaxResult)">{{ form.queryTaxResult || '-' }}</el-tag>
+          </div>
+        </el-card>
+      </el-col>
+
+      <!-- 右侧:累计数据 -->
+      <el-col :xs="24" :md="14" :lg="16">
+        <el-card shadow="hover" class="panel-card">
+          <div slot="header" class="panel-title">累计数据查询</div>
+          <el-row :gutter="8" class="mb16 date-row">
+            <el-col :span="8">
+              <el-input-number v-model="queryYear" :min="2000" :max="2099" size="small" controls-position="right" @change="onQueryDateChange" />
+              <span class="unit">年</span>
+            </el-col>
+            <el-col :span="8">
+              <el-input-number v-model="queryMonth" :min="1" :max="12" size="small" controls-position="right" @change="onQueryDateChange" />
+              <span class="unit">月</span>
+            </el-col>
+            <el-col :span="8">
+              <el-input-number v-model="queryDay" :min="1" :max="31" size="small" controls-position="right" @change="onQueryDateChange" />
+              <span class="unit">日</span>
+            </el-col>
+          </el-row>
+          <el-button-group class="mb16 btn-group-wrap">
+            <el-button type="primary" size="small" @click="queryAccumulated(BUTTON_TYPE.QUERY_CURRENT)">查当次及总累</el-button>
+            <el-button type="primary" size="small" @click="queryAccumulated(BUTTON_TYPE.QUERY_DAY)">查日累</el-button>
+            <el-button type="primary" size="small" @click="queryAccumulated(BUTTON_TYPE.QUERY_MONTH)">查月累</el-button>
+          </el-button-group>
+          <el-row :gutter="12">
+            <el-col :xs="12" :sm="8"><div class="result-item"><span class="label">金额:</span>{{ formatNum(form.amount) }}</div></el-col>
+            <el-col :xs="12" :sm="8"><div class="result-item"><span class="label">油量:</span>{{ formatNum(form.volume) }}</div></el-col>
+            <el-col :xs="12" :sm="8"><div class="result-item"><span class="label">单价:</span>{{ formatNum(form.unitPrice) }}</div></el-col>
+            <el-col :xs="12" :sm="8"><div class="result-item"><span class="label">是否密文:</span>{{ encryptionText }}</div></el-col>
+            <el-col :xs="12" :sm="8"><div class="result-item"><span class="label">总金额:</span>{{ formatNum(form.totalAmount) }}</div></el-col>
+            <el-col :xs="12" :sm="8"><div class="result-item"><span class="label">总油量:</span>{{ formatNum(form.totalVolume) }}</div></el-col>
+            <el-col :span="24"><div class="result-item"><span class="label">执行结果:</span>
+              <el-tag size="mini" :type="resultTag(form.queryAccumulatedResult)">{{ form.queryAccumulatedResult || '-' }}</el-tag>
+            </div></el-col>
+          </el-row>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script>
+import { getTsbWsBind } from '@/api/tsb/ws'
+import tsbWebSocket from '@/utils/tsbWebSocket'
+import { getRouteByCmdType } from '@/utils/tsbCmdRoute'
+
+const PAGE_CMD = 'common:tax'
+const PAGE_ROUTE = getRouteByCmdType(PAGE_CMD)
+
+/** 按钮操作类型 */
+const BUTTON_TYPE = {
+  QUERY: 'common:tax:query',
+  QUERY_CURRENT: 'common:tax:queryCurrent',
+  QUERY_DAY: 'common:tax:queryDay',
+  QUERY_MONTH: 'common:tax:queryMonth'
+}
+
+/** 报税口页面展示字段(与接口文档 data 映射一致) */
+const TAX_DISPLAY_FIELDS = [
+  'interfaceNo',
+  'gunNo',
+  'newNationalStandard',
+  'newNationalStandardTaxNo',
+  'queryDate',
+  'taxNo',
+  'manufacturer',
+  'gunNumber',
+  'queryTaxResult',
+  'amount',
+  'volume',
+  'unitPrice',
+  'encryption',
+  'totalAmount',
+  'totalVolume',
+  'queryAccumulatedResult'
+]
+
+function createDefaultForm() {
+  const now = new Date()
+  const m = String(now.getMonth() + 1).padStart(2, '0')
+  const d = String(now.getDate()).padStart(2, '0')
+  return {
+    interfaceNo: 'A',
+    gunNo: 1,
+    newNationalStandard: 0,
+    newNationalStandardTaxNo: 1,
+    queryDate: `${now.getFullYear()}-${m}-${d}`,
+    taxNo: '',
+    manufacturer: '',
+    gunNumber: null,
+    queryTaxResult: '',
+    amount: null,
+    volume: null,
+    unitPrice: null,
+    encryption: null,
+    totalAmount: null,
+    totalVolume: null,
+    queryAccumulatedResult: ''
+  }
+}
+
+function createDefaultDateParts() {
+  const now = new Date()
+  return {
+    queryYear: now.getFullYear(),
+    queryMonth: now.getMonth() + 1,
+    queryDay: now.getDate()
+  }
+}
+
+export default {
+  name: 'TsbTaxPage',
+  data() {
+    return {
+      BUTTON_TYPE,
+      bindInfo: {},
+      wsConnected: false,
+      newStandardOn: false,
+      ...createDefaultDateParts(),
+      form: createDefaultForm(),
+      wsTimer: null
+    }
+  },
+  computed: {
+    encryptionText() {
+      if (this.form.encryption === 1) return '是'
+      if (this.form.encryption === 0) return '否'
+      return '-'
+    }
+  },
+  watch: {
+    '$store.state.tsb.pageDataVersion'() {
+      this.tryApplyWsPageData(false)
+    }
+  },
+  created() {
+    this.loadBind()
+    tsbWebSocket.connect()
+    this._enteredByCreated = true
+    this.initPageData()
+    this.wsTimer = setInterval(() => {
+      this.wsConnected = tsbWebSocket.isConnected()
+    }, 1000)
+  },
+  activated() {
+    if (this.tryApplyWsPageData(true)) {
+      return
+    }
+    if (this._enteredByCreated) {
+      this._enteredByCreated = false
+      return
+    }
+    this.applyManualDefaults()
+  },
+  beforeDestroy() {
+    if (this.wsTimer) clearInterval(this.wsTimer)
+  },
+  methods: {
+    loadBind() {
+      getTsbWsBind().then(res => {
+        this.bindInfo = res.data || {}
+      }).catch(() => {
+        this.bindInfo = {}
+      })
+    },
+    reconnectWs() {
+      tsbWebSocket.reconnect()
+    },
+    /** WebSocket 推送进入:用 data 初始化;手动菜单进入:恢复默认值并下发 */
+    initPageData() {
+      if (this.tryApplyWsPageData(true)) {
+        return
+      }
+      this.applyManualDefaults()
+    },
+    /** 手动进入:重置为默认值并同步到设备 */
+    applyManualDefaults() {
+      this.resetFormDefaults()
+      this.syncDefaultParamsToDevice()
+    },
+    /** 从 store 应用 WebSocket 推送数据 */
+    tryApplyWsPageData(clearInitFlag) {
+      const data = this.$store.state.tsb.pageData[PAGE_CMD]
+      const initFromWs = this.$store.state.tsb.initFromWs[PAGE_CMD]
+      if (!data) {
+        return false
+      }
+      if (initFromWs) {
+        this.applyTaxData(data)
+        if (clearInitFlag) {
+          this.$store.commit('tsb/CLEAR_WS_INIT', PAGE_CMD)
+        }
+        return true
+      }
+      if (this.$route.path === PAGE_ROUTE) {
+        this.applyTaxData(data)
+        return true
+      }
+      return false
+    },
+    resetFormDefaults() {
+      const dateParts = createDefaultDateParts()
+      this.queryYear = dateParts.queryYear
+      this.queryMonth = dateParts.queryMonth
+      this.queryDay = dateParts.queryDay
+      this.form = createDefaultForm()
+      this.newStandardOn = false
+    },
+    applyTaxData(data) {
+      if (!data) {
+        return
+      }
+      const defaults = createDefaultForm()
+      TAX_DISPLAY_FIELDS.forEach((key) => {
+        if (Object.prototype.hasOwnProperty.call(data, key)) {
+          this.form[key] = data[key] ?? defaults[key]
+        }
+      })
+      if (Object.prototype.hasOwnProperty.call(data, 'newNationalStandard')) {
+        this.newStandardOn = data.newNationalStandard === 1
+      }
+      if (Object.prototype.hasOwnProperty.call(data, 'queryDate')) {
+        this.parseQueryDate(data.queryDate)
+      }
+    },
+    parseQueryDate(dateStr) {
+      if (dateStr == null || dateStr === '') {
+        return
+      }
+      const parts = dateStr.split('-')
+      if (parts.length === 3) {
+        this.queryYear = Number(parts[0])
+        this.queryMonth = Number(parts[1])
+        this.queryDay = Number(parts[2])
+      }
+    },
+    updateQueryDate() {
+      const m = String(this.queryMonth).padStart(2, '0')
+      const d = String(this.queryDay).padStart(2, '0')
+      this.form.queryDate = `${this.queryYear}-${m}-${d}`
+    },
+    /** 手动进入时下发默认参数区字段 */
+    syncDefaultParamsToDevice() {
+      this.updateQueryDate()
+      const payload = {
+        interfaceNo: this.form.interfaceNo,
+        gunNo: this.form.gunNo,
+        newNationalStandard: this.form.newNationalStandard,
+        newNationalStandardTaxNo: this.form.newNationalStandardTaxNo,
+        queryDate: this.form.queryDate
+      }
+      const send = () => tsbWebSocket.sendPageSync(PAGE_CMD, payload)
+      if (send()) {
+        return
+      }
+      let retries = 0
+      const timer = setInterval(() => {
+        if (send() || ++retries >= 10) {
+          clearInterval(timer)
+        }
+      }, 500)
+    },
+    /** 字段变更:仅下发变更字段 */
+    syncField(payload) {
+      tsbWebSocket.sendPageSync(PAGE_CMD, payload)
+    },
+    onInterfaceNoChange(val) {
+      this.syncField({ interfaceNo: val })
+    },
+    onGunNoChange(val) {
+      this.syncField({ gunNo: val })
+    },
+    onNewStandardChange(val) {
+      this.form.newNationalStandard = val ? 1 : 0
+      this.syncField({ newNationalStandard: this.form.newNationalStandard })
+    },
+    onNewNationalStandardTaxNoChange(val) {
+      this.syncField({ newNationalStandardTaxNo: val })
+    },
+    onQueryDateChange() {
+      this.updateQueryDate()
+      this.syncField({ queryDate: this.form.queryDate })
+    },
+    /** 按钮点击:下发参数区字段 + buttonType */
+    buildButtonPayload(buttonType) {
+      this.updateQueryDate()
+      return {
+        interfaceNo: this.form.interfaceNo,
+        gunNo: this.form.gunNo,
+        newNationalStandard: this.form.newNationalStandard,
+        newNationalStandardTaxNo: this.form.newNationalStandardTaxNo,
+        queryDate: this.form.queryDate,
+        buttonType
+      }
+    },
+    sendButtonAction(buttonType) {
+      tsbWebSocket.sendPageSync(PAGE_CMD, this.buildButtonPayload(buttonType))
+    },
+    queryTaxSerial() {
+      this.sendButtonAction(BUTTON_TYPE.QUERY)
+    },
+    queryAccumulated(buttonType) {
+      this.sendButtonAction(buttonType)
+    },
+    formatNum(v) {
+      return v == null || v === '' ? '-' : v
+    },
+    resultTag(text) {
+      if (!text) return 'info'
+      return text.indexOf('成功') >= 0 ? 'success' : 'danger'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.tax-page .mb16 { margin-bottom: 16px; }
+.field-label { font-size: 13px; color: #606266; margin-bottom: 6px; }
+.bind-text { margin-left: 12px; color: #909399; font-size: 13px; }
+.panel-card { min-height: 280px; }
+.panel-title { font-weight: 600; }
+.result-item { margin-bottom: 10px; font-size: 14px; line-height: 1.6; }
+.result-item .label { color: #909399; }
+.date-row .unit { margin-left: 4px; font-size: 13px; color: #606266; }
+.btn-group-wrap { display: flex; flex-wrap: wrap; gap: 8px; }
+.param-row >>> .el-select, .param-row >>> .el-input-number { width: 100%; }
+</style>

+ 25 - 7
ruoyi-ui/vue.config.js

@@ -13,6 +13,30 @@ const baseUrl = 'http://localhost:8080' // 后端接口
 
 const port = process.env.port || process.env.npm_config_port || 80 // 端口
 
+/** 开发环境 API / WebSocket 代理,捕获后端不可达时的 socket 错误,避免 dev-server 崩溃 */
+function createDevProxy(apiPrefix) {
+  return {
+    target: baseUrl,
+    changeOrigin: true,
+    ws: true,
+    pathRewrite: {
+      ['^' + apiPrefix]: ''
+    },
+    onError(err, req, res) {
+      console.warn('[dev-proxy]', err.code || err.message, req.url)
+      if (res && !res.headersSent) {
+        res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' })
+        res.end('后端服务不可用,请确认 Java 服务已启动(' + baseUrl + ')')
+      }
+    },
+    onProxyReqWs(proxyReq, req, socket) {
+      socket.on('error', (err) => {
+        console.warn('[dev-proxy][ws]', err.code || err.message, req.url)
+      })
+    }
+  }
+}
+
 // vue.config.js 配置说明
 //官方vue.config.js 参考文档 https://cli.vuejs.org/zh/config/#css-loaderoptions
 // 这里只列一部分,具体配置参考文档
@@ -35,13 +59,7 @@ module.exports = {
     open: true,
     proxy: {
       // detail: https://cli.vuejs.org/config/#devserver-proxy
-      [process.env.VUE_APP_BASE_API]: {
-        target: baseUrl,
-        changeOrigin: true,
-        pathRewrite: {
-          ['^' + process.env.VUE_APP_BASE_API]: ''
-        }
-      },
+      [process.env.VUE_APP_BASE_API]: createDevProxy(process.env.VUE_APP_BASE_API),
       // springdoc proxy
       '^/v3/api-docs/(.*)': {
         target: baseUrl,

+ 90 - 0
sql/tsb_3.0.sql

@@ -64,3 +64,93 @@ INSERT INTO sys_role_menu VALUES ('2', '3203');
 INSERT INTO sys_role_menu VALUES ('2', '3204');
 INSERT INTO sys_role_menu VALUES ('2', '3205');
 INSERT INTO sys_role_menu VALUES ('2', '3206');
+
+
+
+-- ----------------------------
+-- 调试宝终端功能菜单权限(若依 sys_menu)
+-- 依据 tsb_home_page.lua:第一层为选项卡目录(M),第二层为功能菜单(C)
+-- 目录 menu_id 按 100 跨度预留,便于后续扩展按钮权限(F)
+-- 权限标识 下发至设备登录 permissions 字段,供终端按功能鉴权
+-- icon:目录与菜单均使用若依内置 SVG 图标名
+-- ----------------------------
+
+-- ========== 第一层:目录(对应 tab_names,menu_id 3300/3400/3500/3600) ==========
+
+-- 常用功能
+INSERT INTO sys_menu VALUES ('3300', '常用功能', '0', '6', 'app-common', NULL, '', '', 1, 0, 'M', '0', '0', '', 'dashboard', 'admin', sysdate(), '', NULL, '终端-常用功能目录');
+-- 目标设备监测
+INSERT INTO sys_menu VALUES ('3400', '目标设备监测', '0', '7', 'app-monitor', NULL, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 'admin', sysdate(), '', NULL, '终端-目标设备监测目录');
+-- 目标设备管理
+INSERT INTO sys_menu VALUES ('3500', '目标设备管理', '0', '8', 'app-manage', NULL, '', '', 1, 0, 'M', '0', '0', '', 'tool', 'admin', sysdate(), '', NULL, '终端-目标设备管理目录');
+-- 本机设备管理
+INSERT INTO sys_menu VALUES ('3600', '本机设备管理', '0', '9', 'app-local', NULL, '', '', 1, 0, 'M', '0', '0', '', 'phone', 'admin', sysdate(), '', NULL, '终端-本机设备管理目录');
+
+
+-- ========== 第二层:菜单(对应 demos_by_tab) ==========
+
+-- ---------- 常用功能(3301-3399 预留) ----------
+INSERT INTO sys_menu VALUES ('3301', '报税口',     '3300', '1', 'tax', 'app-common/tax/index', '', '', 1, 0, 'C', '0', '0', 'common:tax',      'money',         'admin', sysdate(), '', NULL, '报税口 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3302', '液位仪',     '3300', '2', 'opw', 'app-common/opw/index', '', '', 1, 0, 'C', '0', '0', 'common:opw',      'slider',        'admin', sysdate(), '', NULL, '液位仪 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3303', '提枪信号',   '3300', '3', 'raise', 'app-common/raise/index', '', '', 1, 0, 'C', '0', '0', 'common:raise',       'radio',         'admin', sysdate(), '', NULL, '提枪信号 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3304', '编码器',     '3300', '4', 'coder', 'app-common/coder/index', '', '', 1, 0, 'C', '0', '0', 'common:coder',      'code',          'admin', sysdate(), '', NULL, '编码器 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3305', '简易示波器', '3300', '5', 'oscilloscope', 'app-common/oscilloscope/index', '', '', 1, 0, 'C', '0', '0', 'common:oscilloscope', 'chart',         'admin', sysdate(), '', NULL, '简易示波器 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3306', '帮助',       '3300', '6', 'help', 'app-common/help/index', '', '', 1, 0, 'C', '0', '0', 'common:help',     'question',      'admin', sysdate(), '', NULL, '帮助 Web 镜像页');
+
+-- ---------- 目标设备监测(3401-3499 预留) ----------
+INSERT INTO sys_menu VALUES ('3401', '日志监测',       '3400', '1', 'devlog', 'app-monitor/devlog/index', '', '', 1, 0, 'C', '0', '0', 'monitor:devlog', 'log',         'admin', sysdate(), '', NULL, '日志监测 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3402', '485监测',        '3400', '2', '485', 'app-monitor/485/index', '', '', 1, 0, 'C', '0', '0', 'monitor:485',    'component',   'admin', sysdate(), '', NULL, '485监测 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3403', '232监测',        '3400', '3', '232', 'app-monitor/232/index', '', '', 1, 0, 'C', '0', '0', 'monitor:232',    'link',        'admin', sysdate(), '', NULL, '232监测 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3404', '射频局域网监测', '3400', '4', 'lora', 'app-monitor/lora/index', '', '', 1, 0, 'C', '0', '0', 'monitor:lora',   'online',      'admin', sysdate(), '', NULL, '射频局域网监测 Web 镜像页');
+
+-- ---------- 目标设备管理(3501-3599 预留) ----------
+INSERT INTO sys_menu VALUES ('3501', '固件包下载', '3500', '1', 'firm', 'app-manage/firm/index', '', '', 1, 0, 'C', '0', '0', 'manage:firm',    'download',      'admin', sysdate(), '', NULL, '固件包下载 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3502', '升级',       '3500', '2', 'upgrade', 'app-manage/upgrade/index', '', '', 1, 0, 'C', '0', '0', 'manage:upgrade', 'upload',        'admin', sysdate(), '', NULL, '升级 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3503', '刷机',       '3500', '3', 'reflash', 'app-manage/reflash/index', '', '', 1, 0, 'C', '0', '0', 'manage:reflash', 'build',         'admin', sysdate(), '', NULL, '刷机 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3504', 'MQTT配置',   '3500', '4', 'mqtt', 'app-manage/mqtt/index', '', '', 1, 0, 'C', '0', '0', 'manage:mqtt',    'server',        'admin', sysdate(), '', NULL, 'MQTT配置 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3505', '信道切换',   '3500', '5', 'channel', 'app-manage/channel/index', '', '', 1, 0, 'C', '0', '0', 'manage:channel', 'switch',        'admin', sysdate(), '', NULL, '信道切换 Web 镜像页');
+
+-- ---------- 本机设备管理(3601-3699 预留) ----------
+INSERT INTO sys_menu VALUES ('3601', '本机信息', '3600', '1', 'info', 'app-local/info/index', '', '', 1, 0, 'C', '0', '0', 'local:info', 'documentation', 'admin', sysdate(), '', NULL, '本机信息 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3602', '本机升级', '3600', '2', 'upgrade', 'app-local/upgrade/index', '', '', 1, 0, 'C', '0', '0', 'local:upgrade',  'more-up',       'admin', sysdate(), '', NULL, '本机升级 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3603', '本机日志', '3600', '3', 'log', 'app-local/log/index', '', '', 1, 0, 'C', '0', '0', 'local:log',     'logininfor',    'admin', sysdate(), '', NULL, '本机日志 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3604', '亮度管理', '3600', '4', 'light', 'app-local/light/index', '', '', 1, 0, 'C', '0', '0', 'local:light',   'theme',         'admin', sysdate(), '', NULL, '亮度管理 Web 镜像页');
+INSERT INTO sys_menu VALUES ('3605', '网络设置', '3600', '5', 'wlan', 'app-local/wlan/index', '', '', 1, 0, 'C', '0', '0', 'local:wlan',    'international', 'admin', sysdate(), '', NULL, '网络设置 Web 镜像页');
+
+
+-- ----------------------------
+-- 普通角色 role_id=2 授权(按需调整角色 ID;超级管理员默认 *:*:*)
+-- 游客模式可见子集见 tsb_home_page.lua demos_by_tab_visitor,可在角色管理中单独配置
+-- ----------------------------
+INSERT INTO sys_role_menu VALUES ('2', '3300');
+INSERT INTO sys_role_menu VALUES ('2', '3301');
+INSERT INTO sys_role_menu VALUES ('2', '3302');
+INSERT INTO sys_role_menu VALUES ('2', '3303');
+INSERT INTO sys_role_menu VALUES ('2', '3304');
+INSERT INTO sys_role_menu VALUES ('2', '3305');
+INSERT INTO sys_role_menu VALUES ('2', '3306');
+
+INSERT INTO sys_role_menu VALUES ('2', '3400');
+INSERT INTO sys_role_menu VALUES ('2', '3401');
+INSERT INTO sys_role_menu VALUES ('2', '3402');
+INSERT INTO sys_role_menu VALUES ('2', '3403');
+INSERT INTO sys_role_menu VALUES ('2', '3404');
+
+INSERT INTO sys_role_menu VALUES ('2', '3500');
+INSERT INTO sys_role_menu VALUES ('2', '3501');
+INSERT INTO sys_role_menu VALUES ('2', '3502');
+INSERT INTO sys_role_menu VALUES ('2', '3503');
+INSERT INTO sys_role_menu VALUES ('2', '3504');
+INSERT INTO sys_role_menu VALUES ('2', '3505');
+
+INSERT INTO sys_role_menu VALUES ('2', '3600');
+INSERT INTO sys_role_menu VALUES ('2', '3601');
+INSERT INTO sys_role_menu VALUES ('2', '3602');
+INSERT INTO sys_role_menu VALUES ('2', '3603');
+INSERT INTO sys_role_menu VALUES ('2', '3604');
+INSERT INTO sys_role_menu VALUES ('2', '3605');
+
+
+
+
+