Procházet zdrojové kódy

1、调试宝的 液位仪的 上行/下行报文

liweimin před 1 dnem
rodič
revize
893be0287c
27 změnil soubory, kde provedl 1447 přidání a 25 odebrání
  1. 29 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/domain/decoder/OpwMainDataUp.java
  2. 42 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/domain/decoder/OpwPassthroughDataUp.java
  3. 84 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/domain/decoder/OpwQueryDataUp.java
  4. 146 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/domain/decoder/opw/OpwTankRow.java
  5. 43 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/domain/encoder/OpwMainDataDown.java
  6. 17 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/domain/encoder/OpwPassthroughDataDown.java
  7. 30 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/domain/encoder/OpwQueryDataDown.java
  8. 3 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/enums/CmdTypeEnum.java
  9. 55 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/service/OpwMainDataUpService.java
  10. 55 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/service/OpwPassthroughDataUpService.java
  11. 55 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/service/OpwQueryDataUpService.java
  12. 39 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/encoder/json/service/OpwMainDataDownService.java
  13. 39 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/encoder/json/service/OpwPassthroughDataDownService.java
  14. 39 0
      ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/encoder/json/service/OpwQueryDataDownService.java
  15. 19 2
      ruoyi-device/src/main/java/com/ruoyi/device/websocket/TsbWebSocketService.java
  16. 7 0
      ruoyi-ui/src/store/getters.js
  17. 9 0
      ruoyi-ui/src/store/modules/tsb.js
  18. 22 2
      ruoyi-ui/src/utils/tsbCmdRoute.js
  19. 60 0
      ruoyi-ui/src/utils/tsbOpwConfig.js
  20. 32 16
      ruoyi-ui/src/utils/tsbWsRouter.js
  21. 139 0
      ruoyi-ui/src/views/app-common/opw/index.vue
  22. 137 0
      ruoyi-ui/src/views/app-common/opw/main.vue
  23. 113 0
      ruoyi-ui/src/views/app-common/opw/passthrough.vue
  24. 203 0
      ruoyi-ui/src/views/app-common/opw/query.vue
  25. 1 1
      ruoyi-ui/src/views/tsb/device-shell/deviceMenus.js
  26. 3 0
      ruoyi-ui/src/views/tsb/device-shell/home.vue
  27. 26 4
      ruoyi-ui/src/views/tsb/device-shell/index.vue

+ 29 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/domain/decoder/OpwMainDataUp.java

@@ -0,0 +1,29 @@
+package com.ruoyi.device.mqtt.domain.decoder;
+
+import com.ruoyi.device.mqtt.domain.BaseJsonBody;
+
+/**
+ * 液位仪主页上行数据
+ *
+ * @author lwm
+ */
+public class OpwMainDataUp extends BaseJsonBody
+{
+    /** 波特率(2400 ~ 115200,默认 9600) */
+    private String baudRate;
+
+    public OpwMainDataUp()
+    {
+        super();
+    }
+
+    public String getBaudRate()
+    {
+        return baudRate;
+    }
+
+    public void setBaudRate(String baudRate)
+    {
+        this.baudRate = baudRate;
+    }
+}

+ 42 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/domain/decoder/OpwPassthroughDataUp.java

@@ -0,0 +1,42 @@
+package com.ruoyi.device.mqtt.domain.decoder;
+
+import com.ruoyi.device.mqtt.domain.BaseJsonBody;
+
+/**
+ * 液位仪透传页上行数据
+ *
+ * @author lwm
+ */
+public class OpwPassthroughDataUp extends BaseJsonBody
+{
+    /** A口接收数据 */
+    private String aPortData;
+
+    /** B口接收数据 */
+    private String bPortData;
+
+    public OpwPassthroughDataUp()
+    {
+        super();
+    }
+
+    public String getAPortData()
+    {
+        return aPortData;
+    }
+
+    public void setAPortData(String aPortData)
+    {
+        this.aPortData = aPortData;
+    }
+
+    public String getBPortData()
+    {
+        return bPortData;
+    }
+
+    public void setBPortData(String bPortData)
+    {
+        this.bPortData = bPortData;
+    }
+}

+ 84 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/domain/decoder/OpwQueryDataUp.java

@@ -0,0 +1,84 @@
+package com.ruoyi.device.mqtt.domain.decoder;
+
+import com.ruoyi.device.mqtt.domain.BaseJsonBody;
+import com.ruoyi.device.mqtt.domain.decoder.opw.OpwTankRow;
+
+import java.util.List;
+
+/**
+ * 液位仪查询页上行数据
+ *
+ * @author lwm
+ */
+public class OpwQueryDataUp extends BaseJsonBody
+{
+    /** 总罐数 */
+    private Integer totalTankCount;
+
+    /** 执行结果 */
+    private String queryResult;
+
+    /** 原始日志 HEX 文本 */
+    private String rawLog;
+
+    /** 罐表数据 */
+    private List<OpwTankRow> tankList;
+
+    /** 按钮操作(A口/B口/485查询、查看原始日志等) */
+    private String buttonType;
+
+    public OpwQueryDataUp()
+    {
+        super();
+    }
+
+    public Integer getTotalTankCount()
+    {
+        return totalTankCount;
+    }
+
+    public void setTotalTankCount(Integer totalTankCount)
+    {
+        this.totalTankCount = totalTankCount;
+    }
+
+    public String getQueryResult()
+    {
+        return queryResult;
+    }
+
+    public void setQueryResult(String queryResult)
+    {
+        this.queryResult = queryResult;
+    }
+
+    public String getRawLog()
+    {
+        return rawLog;
+    }
+
+    public void setRawLog(String rawLog)
+    {
+        this.rawLog = rawLog;
+    }
+
+    public List<OpwTankRow> getTankList()
+    {
+        return tankList;
+    }
+
+    public void setTankList(List<OpwTankRow> tankList)
+    {
+        this.tankList = tankList;
+    }
+
+    public String getButtonType()
+    {
+        return buttonType;
+    }
+
+    public void setButtonType(String buttonType)
+    {
+        this.buttonType = buttonType;
+    }
+}

+ 146 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/domain/decoder/opw/OpwTankRow.java

@@ -0,0 +1,146 @@
+package com.ruoyi.device.mqtt.domain.decoder.opw;
+
+import java.math.BigDecimal;
+
+/**
+ * 液位仪查询页罐表行数据
+ *
+ * @author lwm
+ */
+public class OpwTankRow
+{
+    /** 罐号 */
+    private String tankNo;
+
+    /** 油水体积1 */
+    private BigDecimal oilWaterVolume1;
+
+    /** 油水体积2 */
+    private BigDecimal oilWaterVolume2;
+
+    /** 剩余体积 */
+    private BigDecimal remainingVolume;
+
+    /** 油高 */
+    private BigDecimal oilHeight;
+
+    /** 水高 */
+    private BigDecimal waterHeight;
+
+    /** 温度 */
+    private BigDecimal temperature;
+
+    /** 水体积 */
+    private BigDecimal waterVolume;
+
+    /** 串口(如 A口、B口) */
+    private String serialPort;
+
+    /** 读取时间 */
+    private String readTime;
+
+    public OpwTankRow()
+    {
+        super();
+    }
+
+    public String getTankNo()
+    {
+        return tankNo;
+    }
+
+    public void setTankNo(String tankNo)
+    {
+        this.tankNo = tankNo;
+    }
+
+    public BigDecimal getOilWaterVolume1()
+    {
+        return oilWaterVolume1;
+    }
+
+    public void setOilWaterVolume1(BigDecimal oilWaterVolume1)
+    {
+        this.oilWaterVolume1 = oilWaterVolume1;
+    }
+
+    public BigDecimal getOilWaterVolume2()
+    {
+        return oilWaterVolume2;
+    }
+
+    public void setOilWaterVolume2(BigDecimal oilWaterVolume2)
+    {
+        this.oilWaterVolume2 = oilWaterVolume2;
+    }
+
+    public BigDecimal getRemainingVolume()
+    {
+        return remainingVolume;
+    }
+
+    public void setRemainingVolume(BigDecimal remainingVolume)
+    {
+        this.remainingVolume = remainingVolume;
+    }
+
+    public BigDecimal getOilHeight()
+    {
+        return oilHeight;
+    }
+
+    public void setOilHeight(BigDecimal oilHeight)
+    {
+        this.oilHeight = oilHeight;
+    }
+
+    public BigDecimal getWaterHeight()
+    {
+        return waterHeight;
+    }
+
+    public void setWaterHeight(BigDecimal waterHeight)
+    {
+        this.waterHeight = waterHeight;
+    }
+
+    public BigDecimal getTemperature()
+    {
+        return temperature;
+    }
+
+    public void setTemperature(BigDecimal temperature)
+    {
+        this.temperature = temperature;
+    }
+
+    public BigDecimal getWaterVolume()
+    {
+        return waterVolume;
+    }
+
+    public void setWaterVolume(BigDecimal waterVolume)
+    {
+        this.waterVolume = waterVolume;
+    }
+
+    public String getSerialPort()
+    {
+        return serialPort;
+    }
+
+    public void setSerialPort(String serialPort)
+    {
+        this.serialPort = serialPort;
+    }
+
+    public String getReadTime()
+    {
+        return readTime;
+    }
+
+    public void setReadTime(String readTime)
+    {
+        this.readTime = readTime;
+    }
+}

+ 43 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/domain/encoder/OpwMainDataDown.java

@@ -0,0 +1,43 @@
+package com.ruoyi.device.mqtt.domain.encoder;
+
+import com.ruoyi.device.mqtt.domain.BaseJsonBody;
+
+/**
+ * 液位仪主页下行数据
+ *
+ * @author lwm
+ */
+public class OpwMainDataDown extends BaseJsonBody
+{
+    /** 波特率 */
+    private String baudRate;
+
+    /** 按钮操作 */
+    private String buttonType;
+
+    public OpwMainDataDown()
+    {
+        super();
+    }
+
+    public String getBaudRate()
+    {
+        return baudRate;
+    }
+
+    public void setBaudRate(String baudRate)
+    {
+        this.baudRate = baudRate;
+    }
+
+    public String getButtonType()
+    {
+        return buttonType;
+    }
+
+    public void setButtonType(String buttonType)
+    {
+        this.buttonType = buttonType;
+    }
+
+}

+ 17 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/domain/encoder/OpwPassthroughDataDown.java

@@ -0,0 +1,17 @@
+package com.ruoyi.device.mqtt.domain.encoder;
+
+import com.ruoyi.device.mqtt.domain.BaseJsonBody;
+
+/**
+ * 液位仪透传页下行数据
+ *
+ * @author lwm
+ */
+public class OpwPassthroughDataDown extends BaseJsonBody
+{
+
+    public OpwPassthroughDataDown()
+    {
+        super();
+    }
+}

+ 30 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/domain/encoder/OpwQueryDataDown.java

@@ -0,0 +1,30 @@
+package com.ruoyi.device.mqtt.domain.encoder;
+
+import com.ruoyi.device.mqtt.domain.BaseJsonBody;
+
+/**
+ * 液位仪查询页下行数据
+ *
+ * @author lwm
+ */
+public class OpwQueryDataDown extends BaseJsonBody
+{
+    /** 按钮操作(A口/B口/485查询、查看原始日志等) */
+    private String buttonType;
+
+    public OpwQueryDataDown()
+    {
+        super();
+    }
+
+    public String getButtonType()
+    {
+        return buttonType;
+    }
+
+    public void setButtonType(String buttonType)
+    {
+        this.buttonType = buttonType;
+    }
+
+}

+ 3 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/enums/CmdTypeEnum.java

@@ -12,6 +12,9 @@ public enum CmdTypeEnum
     TSB_LOGIN("tsb:login", "tsb:login:up", "tsb:login:down", "调试宝登录"),
     TSB_PT("tsb:pt", "tsb:pt:up", "tsb:pt:down", "调试宝PT产测"),
     COMMON_TAX("common:tax", "common:tax:up", "common:tax:down", "报税口数据"),
+    COMMON_OPW("common:opw", "common:opw:up", "common:opw:down", "液位仪主页"),
+    COMMON_OPW_QUERY("common:opw:query", "common:opw:query:up", "common:opw:query:down", "液位仪查询"),
+    COMMON_OPW_PASSTHROUGH("common:opw:passthrough", "common:opw:passthrough:up", "common:opw:passthrough:down", "液位仪透传"),
     ;
 
     private final String cmdType;

+ 55 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/service/OpwMainDataUpService.java

@@ -0,0 +1,55 @@
+package com.ruoyi.device.mqtt.handler.decoder.json.service;
+
+import com.alibaba.fastjson2.JSON;
+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.OpwMainDataUp;
+import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
+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:opw:up
+ * <p>
+ * 解析设备页面数据并推送到 WebSocket(无 MQTT 下行应答)。
+ *
+ * @author lwm
+ */
+@JsonCmdUpHandler(cmdType = CmdTypeEnum.COMMON_OPW)
+public class OpwMainDataUpService implements IJsonCmdUpHandler
+{
+    private static final Logger log = LoggerFactory.getLogger(OpwMainDataUpService.class);
+
+    @Resource
+    private TsbWebSocketService tsbWebSocketService;
+
+    @Override
+    public BaseJsonBody handle(CommonTopic topic, CommonHeader header, String bodyJson, CmdTypeEnum cmd)
+    {
+        // 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(), "液位仪报文体解析失败");
+        }
+        OpwMainDataUp opwMainDataUp = bodyObj.to(OpwMainDataUp.class);
+        if (opwMainDataUp == null)
+        {
+            log.warn("液位仪主页报文体解析失败, deviceSn={}", topic.getDeviceSn());
+            return BaseJsonBody.fail(topic.getDeviceType(), topic.getDeviceSn(), cmd.getCmdDownType(), "液位仪报文体解析失败");
+        }
+
+        // 2、推送 Web 端
+        tsbWebSocketService.pushPageSyncFromDevice(cmd.getCmdType(), bodyObj);
+        // 3、无需下行应答
+        return null;
+    }
+}

+ 55 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/service/OpwPassthroughDataUpService.java

@@ -0,0 +1,55 @@
+package com.ruoyi.device.mqtt.handler.decoder.json.service;
+
+import com.alibaba.fastjson2.JSON;
+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.OpwPassthroughDataUp;
+import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
+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:opw:passthrough:up
+ * <p>
+ * 解析设备页面数据并推送到 WebSocket(无 MQTT 下行应答)。
+ *
+ * @author lwm
+ */
+@JsonCmdUpHandler(cmdType = CmdTypeEnum.COMMON_OPW_PASSTHROUGH)
+public class OpwPassthroughDataUpService implements IJsonCmdUpHandler
+{
+    private static final Logger log = LoggerFactory.getLogger(OpwPassthroughDataUpService.class);
+
+    @Resource
+    private TsbWebSocketService tsbWebSocketService;
+
+    @Override
+    public BaseJsonBody handle(CommonTopic topic, CommonHeader header, String bodyJson, CmdTypeEnum cmd)
+    {
+        // 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(), "液位仪透传报文体解析失败");
+        }
+        OpwPassthroughDataUp opwPassthroughDataUp = bodyObj.to(OpwPassthroughDataUp.class);
+        if (opwPassthroughDataUp == null)
+        {
+            log.warn("液位仪透传报文体解析失败, deviceSn={}", topic.getDeviceSn());
+            return BaseJsonBody.fail(topic.getDeviceType(), topic.getDeviceSn(), cmd.getCmdDownType(), "液位仪透传报文体解析失败");
+        }
+
+        // 2、推送 Web 端
+        tsbWebSocketService.pushPageSyncFromDevice(cmd.getCmdType(), bodyObj);
+        // 3、无需下行应答
+        return null;
+    }
+}

+ 55 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/decoder/json/service/OpwQueryDataUpService.java

@@ -0,0 +1,55 @@
+package com.ruoyi.device.mqtt.handler.decoder.json.service;
+
+import com.alibaba.fastjson2.JSON;
+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.OpwQueryDataUp;
+import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
+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.opw.query:up
+ * <p>
+ * 解析设备页面数据并推送到 WebSocket(无 MQTT 下行应答)。
+ *
+ * @author lwm
+ */
+@JsonCmdUpHandler(cmdType = CmdTypeEnum.COMMON_OPW_QUERY)
+public class OpwQueryDataUpService implements IJsonCmdUpHandler
+{
+    private static final Logger log = LoggerFactory.getLogger(OpwQueryDataUpService.class);
+
+    @Resource
+    private TsbWebSocketService tsbWebSocketService;
+
+    @Override
+    public BaseJsonBody handle(CommonTopic topic, CommonHeader header, String bodyJson, CmdTypeEnum cmd)
+    {
+        // 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(), "液位仪查询报文体解析失败");
+        }
+        OpwQueryDataUp opwQueryDataUp = bodyObj.to(OpwQueryDataUp.class);
+        if (opwQueryDataUp == null)
+        {
+            log.warn("液位仪查询报文体解析失败, deviceSn={}", topic.getDeviceSn());
+            return BaseJsonBody.fail(topic.getDeviceType(), topic.getDeviceSn(), cmd.getCmdDownType(), "液位仪查询报文体解析失败");
+        }
+
+        // 2、推送 Web 端
+        tsbWebSocketService.pushPageSyncFromDevice(cmd.getCmdType(), bodyObj);
+        // 3、无需下行应答
+        return null;
+    }
+}

+ 39 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/encoder/json/service/OpwMainDataDownService.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.OpwMainDataDown;
+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:opw:down
+ * <p>
+ * 收到 WebSocket页面数据并推送到 设备 MQTT 下行应答
+ *
+ * @author lwm
+ */
+@JsonCmdDownHandler(cmdType = CmdTypeEnum.COMMON_OPW)
+public class OpwMainDataDownService implements IJsonCmdDownHandler
+{
+    private static final Logger log = LoggerFactory.getLogger(OpwMainDataDownService.class);
+
+    @Override
+    public BaseJsonBody handle(TsbUserDeviceBind bind, JSONObject data, CmdTypeEnum cmd)
+    {
+        OpwMainDataDown opwMainDataDown = data.to(OpwMainDataDown.class);
+        if (opwMainDataDown == null)
+        {
+            log.warn("液位仪主页报文体解析失败, deviceSn={}", bind.getDeviceSn());
+            return null;
+        }
+        opwMainDataDown.setDeviceType(bind.getDeviceType());
+        opwMainDataDown.setDeviceSn(bind.getDeviceSn());
+        opwMainDataDown.setCmdType(cmd.getCmdDownType());
+        return opwMainDataDown;
+    }
+}

+ 39 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/encoder/json/service/OpwPassthroughDataDownService.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.OpwPassthroughDataDown;
+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:opw:passthrough:down
+ * <p>
+ * 收到 WebSocket页面数据并推送到 设备 MQTT 下行应答
+ *
+ * @author lwm
+ */
+@JsonCmdDownHandler(cmdType = CmdTypeEnum.COMMON_OPW_PASSTHROUGH)
+public class OpwPassthroughDataDownService implements IJsonCmdDownHandler
+{
+    private static final Logger log = LoggerFactory.getLogger(OpwPassthroughDataDownService.class);
+
+    @Override
+    public BaseJsonBody handle(TsbUserDeviceBind bind, JSONObject data, CmdTypeEnum cmd)
+    {
+        OpwPassthroughDataDown opwPassthroughDataDown = data.to(OpwPassthroughDataDown.class);
+        if (opwPassthroughDataDown == null)
+        {
+            log.warn("液位仪透传报文体解析失败, deviceSn={}", bind.getDeviceSn());
+            return null;
+        }
+        opwPassthroughDataDown.setDeviceType(bind.getDeviceType());
+        opwPassthroughDataDown.setDeviceSn(bind.getDeviceSn());
+        opwPassthroughDataDown.setCmdType(cmd.getCmdDownType());
+        return opwPassthroughDataDown;
+    }
+}

+ 39 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/handler/encoder/json/service/OpwQueryDataDownService.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.OpwQueryDataDown;
+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:opw:query:down
+ * <p>
+ * 收到 WebSocket页面数据并推送到 设备 MQTT 下行应答
+ *
+ * @author lwm
+ */
+@JsonCmdDownHandler(cmdType = CmdTypeEnum.COMMON_OPW_QUERY)
+public class OpwQueryDataDownService implements IJsonCmdDownHandler
+{
+    private static final Logger log = LoggerFactory.getLogger(OpwQueryDataDownService.class);
+
+    @Override
+    public BaseJsonBody handle(TsbUserDeviceBind bind, JSONObject data, CmdTypeEnum cmd)
+    {
+        OpwQueryDataDown opwQueryDataDown = data.to(OpwQueryDataDown.class);
+        if (opwQueryDataDown == null)
+        {
+            log.warn("液位仪查询报文体解析失败, deviceSn={}", bind.getDeviceSn());
+            return null;
+        }
+        opwQueryDataDown.setDeviceType(bind.getDeviceType());
+        opwQueryDataDown.setDeviceSn(bind.getDeviceSn());
+        opwQueryDataDown.setCmdType(cmd.getCmdDownType());
+        return opwQueryDataDown;
+    }
+}

+ 19 - 2
ruoyi-device/src/main/java/com/ruoyi/device/websocket/TsbWebSocketService.java

@@ -199,8 +199,7 @@ public class TsbWebSocketService
         }
         Set<String> userPermissions = getUserPermissionsFromSession(session);
         Long userId = getUserIdFromSession(session);
-        if (StringUtils.isEmpty(userPermissions) ||
-                (!userPermissions.contains(tsbWebSocketMessage.getCmdType()) && 1L != userId))
+        if (!hasPagePermission(userPermissions, tsbWebSocketMessage.getCmdType(), userId))
         {
             log.warn("WebSocket 用户权限不足, sessionId={}, cmdType={}", session.getId(), tsbWebSocketMessage.getCmdType());
             TsbWebSocketUsers.sendMessageToUserByText(session,
@@ -288,6 +287,24 @@ public class TsbWebSocketService
     }
 
     /**
+     * 管理员拥有任意页面权限
+     * 权限为空时,默认为没有权限
+     * 否则,判断用户权限集合中是否包含该权限
+     */
+    private boolean hasPagePermission(Set<String> userPermissions, String cmdType, Long userId)
+    {
+        if (1L == userId)
+        {
+            return true;
+        }
+        if (StringUtils.isEmpty(userPermissions) || StringUtils.isEmpty(cmdType))
+        {
+            return false;
+        }
+        return userPermissions.contains(cmdType);
+    }
+
+    /**
      * 设备 MQTT 上行解析后推送到 Web端
      */
     public void pushPageSyncFromDevice(String pageKey, JSONObject bodyObj)

+ 7 - 0
ruoyi-ui/src/store/getters.js

@@ -30,6 +30,13 @@ const getters = {
       return 'home'
     }
     return state.tsb.devicePanelMap[String(sn)] || 'home'
+  },
+  tsbOpwSubPage: state => {
+    const sn = state.tsb.currentDevice && state.tsb.currentDevice.deviceSn
+    if (sn == null) {
+      return 'main'
+    }
+    return state.tsb.opwSubPageMap[String(sn)] || 'main'
   }
 }
 export default getters

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

@@ -19,6 +19,8 @@ const tsb = {
     devicePageFormCache: {},
     /** 数据版本号,供页面 watch 实时更新 */
     pageDataVersion: 0,
+    /** 液位仪各设备当前子页:main / query / passthrough */
+    opwSubPageMap: {},
     /** 设备页签切换版本号,供子页面在切回时刷新 WS 数据 */
     deviceSwitchVersion: 0
   },
@@ -96,6 +98,12 @@ const tsb = {
         state.initFromWs = { ...state.initFromWs, [key]: true }
       }
     },
+    SET_OPW_SUB_PAGE(state, { deviceSn, subPage }) {
+      if (deviceSn == null || !subPage) {
+        return
+      }
+      state.opwSubPageMap = { ...state.opwSubPageMap, [String(deviceSn)]: subPage }
+    },
     NOTIFY_DEVICE_SWITCHED(state) {
       state.deviceSwitchVersion += 1
     },
@@ -118,6 +126,7 @@ const tsb = {
       state.devicePageFormCache = {}
       state.pageDataVersion = 0
       state.deviceSwitchVersion = 0
+      state.opwSubPageMap = {}
     }
   }
 }

+ 22 - 2
ruoyi-ui/src/utils/tsbCmdRoute.js

@@ -2,12 +2,17 @@
  * cmdType 与前端路由映射(设备推送页面跳转)
  */
 export const TSB_CMD_ROUTE_MAP = {
-  'common:tax': '/app-common/tax'
+  'common:tax': '/app-common/tax',
+  'common:opw': '/app-common/opw',
+  'common:opw:query': '/app-common/opw',
+  'common:opw:passthrough': '/app-common/opw'
 }
 
 /** 设备工作区固定路由(切换设备不改地址) */
 export const TSB_WORKSPACE_ROUTE = '/tsb/workspace'
 
+import { OPW_CMD_SUB_PAGE } from '@/utils/tsbOpwConfig'
+
 /**
  * 根据 cmdType 获取路由路径
  * @param {string} cmdType
@@ -21,7 +26,8 @@ export function getRouteByCmdType(cmdType, deviceSn) {
   if (deviceSn == null || deviceSn === '') {
     return base
   }
-  if (cmdType === 'common:tax') {
+  if (cmdType === 'common:tax' || cmdType === 'common:opw'
+    || cmdType === 'common:opw:query' || cmdType === 'common:opw:passthrough') {
     return TSB_WORKSPACE_ROUTE
   }
   return base
@@ -44,3 +50,17 @@ export function isOnCmdRoute(path, cmdType, deviceSn, currentPanel) {
   }
   return false
 }
+
+/**
+ * 液位仪子页是否匹配 cmdType
+ */
+export function isOnOpwSubPage(path, cmdType, currentPanel, opwSubPage) {
+  if (path !== TSB_WORKSPACE_ROUTE || currentPanel !== 'opw') {
+    return false
+  }
+  const expected = OPW_CMD_SUB_PAGE[cmdType]
+  if (!expected) {
+    return false
+  }
+  return opwSubPage === expected
+}

+ 60 - 0
ruoyi-ui/src/utils/tsbOpwConfig.js

@@ -0,0 +1,60 @@
+/** 液位仪 cmdType 与 Web 子页映射 */
+export const OPW_CMD = {
+  MAIN: 'common:opw',
+  QUERY: 'common:opw:query',
+  PASSTHROUGH: 'common:opw:passthrough'
+}
+
+export const OPW_SUB_PAGE = {
+  main: OPW_CMD.MAIN,
+  query: OPW_CMD.QUERY,
+  passthrough: OPW_CMD.PASSTHROUGH
+}
+
+export const OPW_CMD_SUB_PAGE = {
+  [OPW_CMD.MAIN]: 'main',
+  [OPW_CMD.QUERY]: 'query',
+  [OPW_CMD.PASSTHROUGH]: 'passthrough'
+}
+
+export const OPW_SUB_PAGE_TITLE = {
+  main: '液位仪',
+  query: '液位仪查询',
+  passthrough: '液位仪透传'
+}
+
+export const OPW_BUTTON = {
+  QUERY_A: 'common:opw:query:a',
+  QUERY_B: 'common:opw:query:b',
+  QUERY_485: 'common:opw:query:485',
+  VIEW_RAW_LOG: 'common:opw:query:rawLog'
+}
+
+export const OPW_BAUDRATES = ['2400', '4800', '9600', '19200', '38400', '57600', '115200']
+
+export const OPW_TANK_COLUMNS = [
+  { prop: 'tankNo', label: '罐号', width: 70 },
+  { prop: 'oilWaterVolume1', label: '油水体积1', width: 100 },
+  { prop: 'oilWaterVolume2', label: '油水体积2', width: 100 },
+  { prop: 'remainingVolume', label: '剩余体积', width: 100 },
+  { prop: 'oilHeight', label: '油高', width: 70 },
+  { prop: 'waterHeight', label: '水高', width: 70 },
+  { prop: 'temperature', label: '温度', width: 70 },
+  { prop: 'waterVolume', label: '水体积', width: 80 },
+  { prop: 'serialPort', label: '串口', width: 70 },
+  { prop: 'readTime', label: '读取时间', width: 150 }
+]
+
+/** 按 key 存在性合并 WS 数据:不存在不更新,null/'' 渲染为空 */
+export function applyPartialFields(target, data, fields, defaults = {}) {
+  if (!data || !target) {
+    return
+  }
+  fields.forEach((key) => {
+    if (!Object.prototype.hasOwnProperty.call(data, key)) {
+      return
+    }
+    const val = data[key]
+    target[key] = val == null || val === '' ? (defaults[key] != null ? defaults[key] : '') : val
+  })
+}

+ 32 - 16
ruoyi-ui/src/utils/tsbWsRouter.js

@@ -1,10 +1,30 @@
 import store from '@/store'
 import router from '@/router'
 import { Message } from 'element-ui'
-import { getRouteByCmdType, isOnCmdRoute, TSB_WORKSPACE_ROUTE } from '@/utils/tsbCmdRoute'
+import {
+  getRouteByCmdType,
+  isOnCmdRoute,
+  isOnOpwSubPage,
+  TSB_WORKSPACE_ROUTE,
+  OPW_CMD_SUB_PAGE
+} from '@/utils/tsbCmdRoute'
 import { navigateToWorkspace } from '@/utils/tsbWorkspaceNav'
 import tsbWebSocket from '@/utils/tsbWebSocket'
 
+function resolveWsData(msg) {
+  if (!msg.data) {
+    return null
+  }
+  if (typeof msg.data === 'string') {
+    try {
+      return JSON.parse(msg.data)
+    } catch (e) {
+      return null
+    }
+  }
+  return msg.data
+}
+
 /**
  * 处理 WebSocket 推送:按 cmdType 跳转页面并缓存 data 供页面初始化
  */
@@ -39,29 +59,25 @@ export function handleTsbWsMessage(msg) {
     return
   }
 
-  if (!msg.data) {
-    return
-  }
-
-  const data = typeof msg.data === 'string'
-    ? (() => {
-      try {
-        return JSON.parse(msg.data)
-      } catch (e) {
-        return null
-      }
-    })()
-    : msg.data
+  const data = resolveWsData(msg)
   if (!data) {
     return
   }
 
-  if (msg.cmdType === 'common:tax' && deviceSn != null && routePath === TSB_WORKSPACE_ROUTE) {
+  const opwSubPage = OPW_CMD_SUB_PAGE[msg.cmdType]
+  if (opwSubPage && deviceSn != null && routePath === TSB_WORKSPACE_ROUTE) {
+    store.commit('tsb/SET_DEVICE_PANEL', { deviceSn, panel: 'opw' })
+    store.commit('tsb/SET_OPW_SUB_PAGE', { deviceSn, subPage: opwSubPage })
+  } else if (msg.cmdType === 'common:tax' && deviceSn != null && routePath === TSB_WORKSPACE_ROUTE) {
     store.commit('tsb/SET_DEVICE_PANEL', { deviceSn, panel: 'tax' })
   }
 
   const devicePanel = (store.state.tsb.devicePanelMap || {})[String(deviceSn)] || 'home'
-  const onTargetPage = isOnCmdRoute(router.currentRoute.path, msg.cmdType, deviceSn, devicePanel)
+  const currentOpwSub = (store.state.tsb.opwSubPageMap || {})[String(deviceSn)] || 'main'
+  const onTargetPage = opwSubPage
+    ? isOnOpwSubPage(router.currentRoute.path, msg.cmdType, devicePanel, currentOpwSub)
+    : isOnCmdRoute(router.currentRoute.path, msg.cmdType, deviceSn, devicePanel)
+
   store.commit('tsb/SET_WS_PAGE_DATA', {
     cmdType: msg.cmdType,
     data,

+ 139 - 0
ruoyi-ui/src/views/app-common/opw/index.vue

@@ -0,0 +1,139 @@
+<template>
+  <div class="app-container opw-page">
+    <el-alert
+      v-if="showNewTabHint"
+      title="当前页签未选择设备,请前往首页选择已登录设备后再操作"
+      type="warning"
+      show-icon
+      :closable="false"
+      class="mb16"
+    />
+    <el-alert
+      v-else-if="!ownerDeviceSn"
+      title="请先在首页选择已登录设备并建立连接"
+      type="warning"
+      show-icon
+      :closable="false"
+      class="mb16"
+    />
+    <template v-if="canOperateDevice">
+      <el-row 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:{{ ownerDeviceSn }}</span>
+        </el-col>
+        <el-col :span="8" style="text-align: right">
+          <el-button size="mini" type="primary" @click="reconnectWs">重连</el-button>
+        </el-col>
+      </el-row>
+      <keep-alive>
+        <component
+          :is="activeComponent"
+          :key="subPageKey"
+          :bound-device-sn="ownerDeviceSn"
+          @navigate="onNavigate"
+        />
+      </keep-alive>
+    </template>
+  </div>
+</template>
+
+<script>
+import tsbWebSocket from '@/utils/tsbWebSocket'
+import OpwMain from './main'
+import OpwQuery from './query'
+import OpwPassthrough from './passthrough'
+
+const SUB_COMPONENTS = {
+  main: OpwMain,
+  query: OpwQuery,
+  passthrough: OpwPassthrough
+}
+
+export default {
+  name: 'TsbOpwPage',
+  components: { OpwMain, OpwQuery, OpwPassthrough },
+  props: {
+    boundDeviceSn: {
+      type: [Number, String],
+      default: null
+    }
+  },
+  data() {
+    return {
+      wsConnected: false,
+      wsTimer: null
+    }
+  },
+  computed: {
+    ownerDeviceSn() {
+      if (this.boundDeviceSn != null && this.boundDeviceSn !== '') {
+        return this.boundDeviceSn
+      }
+      return this.$store.getters.tsbCurrentDeviceSn || tsbWebSocket.getCurrentDeviceSn()
+    },
+    showNewTabHint() {
+      return this.$store.getters.tsbSessionReady && this.$store.getters.tsbIsNewTabWithoutDevice && !this.ownerDeviceSn
+    },
+    canOperateDevice() {
+      const activeSn = this.$store.getters.tsbCurrentDeviceSn
+      return !!this.ownerDeviceSn
+        && String(activeSn) === String(this.ownerDeviceSn)
+        && !this.showNewTabHint
+    },
+    currentSubPage() {
+      const map = this.$store.state.tsb.opwSubPageMap || {}
+      return map[String(this.ownerDeviceSn)] || 'main'
+    },
+    activeComponent() {
+      return SUB_COMPONENTS[this.currentSubPage] || OpwMain
+    },
+    subPageKey() {
+      return `${this.ownerDeviceSn}-${this.currentSubPage}`
+    }
+  },
+  created() {
+    this.ensureSubPage()
+    this.wsTimer = setInterval(() => {
+      this.wsConnected = tsbWebSocket.isConnected()
+    }, 1000)
+  },
+  activated() {
+    this.ensureSubPage()
+  },
+  beforeDestroy() {
+    if (this.wsTimer) {
+      clearInterval(this.wsTimer)
+    }
+  },
+  methods: {
+    ensureSubPage() {
+      if (this.ownerDeviceSn == null) {
+        return
+      }
+      const map = this.$store.state.tsb.opwSubPageMap || {}
+      if (!map[String(this.ownerDeviceSn)]) {
+        this.$store.commit('tsb/SET_OPW_SUB_PAGE', { deviceSn: this.ownerDeviceSn, subPage: 'main' })
+      }
+    },
+    onNavigate(subPage) {
+      if (!subPage || this.ownerDeviceSn == null) {
+        return
+      }
+      this.$store.commit('tsb/SET_OPW_SUB_PAGE', { deviceSn: this.ownerDeviceSn, subPage })
+    },
+    reconnectWs() {
+      tsbWebSocket.reconnect().catch(msg => {
+        this.$message.error(msg || '重连失败')
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.opw-page .mb16 { margin-bottom: 16px; }
+.bind-text { margin-left: 12px; color: #909399; font-size: 13px; }
+</style>

+ 137 - 0
ruoyi-ui/src/views/app-common/opw/main.vue

@@ -0,0 +1,137 @@
+<template>
+  <div class="opw-main">
+    <el-card shadow="never">
+      <el-row :gutter="12" type="flex" align="middle">
+        <el-col :span="8">
+          <div class="field-label">波特率</div>
+          <el-select v-model="form.baudRate" size="small" style="width: 100%" @change="onBaudRateChange">
+            <el-option v-for="item in baudRates" :key="item" :label="item" :value="item" />
+          </el-select>
+        </el-col>
+        <el-col :span="8">
+          <el-button type="primary" size="small" @click="openQuery">液位仪查询</el-button>
+        </el-col>
+        <el-col :span="8">
+          <el-button type="primary" size="small" plain @click="openPassthrough">液位仪透传</el-button>
+        </el-col>
+      </el-row>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import tsbWebSocket from '@/utils/tsbWebSocket'
+import { OPW_CMD, OPW_BAUDRATES, applyPartialFields } from '@/utils/tsbOpwConfig'
+
+const PAGE_CMD = OPW_CMD.MAIN
+const DISPLAY_FIELDS = ['baudRate']
+
+function createDefaultForm() {
+  return { baudRate: '9600' }
+}
+
+export default {
+  name: 'TsbOpwMain',
+  props: {
+    boundDeviceSn: { type: [Number, String], default: null }
+  },
+  data() {
+    return {
+      baudRates: OPW_BAUDRATES,
+      form: createDefaultForm()
+    }
+  },
+  computed: {
+    ownerDeviceSn() {
+      return this.boundDeviceSn
+    },
+    canOperateDevice() {
+      const activeSn = this.$store.getters.tsbCurrentDeviceSn
+      return String(activeSn) === String(this.ownerDeviceSn)
+    }
+  },
+  watch: {
+    '$store.state.tsb.pageDataVersion'() {
+      this.tryApplyWsPageData(false)
+    },
+    '$store.state.tsb.deviceSwitchVersion'() {
+      if (String(this.$store.getters.tsbCurrentDeviceSn) === String(this.ownerDeviceSn)) {
+        this.refreshPageFromStore()
+      }
+    }
+  },
+  activated() {
+    this.refreshPageFromStore()
+  },
+  deactivated() {
+    this.savePageCache()
+  },
+  methods: {
+    pageCacheKey() {
+      return this.ownerDeviceSn != null ? `${this.ownerDeviceSn}::opw-main` : null
+    },
+    pageDataKey() {
+      return this.ownerDeviceSn != null ? `${this.ownerDeviceSn}::${PAGE_CMD}` : PAGE_CMD
+    },
+    savePageCache() {
+      const key = this.pageCacheKey()
+      if (!key) return
+      this.$store.commit('tsb/SET_DEVICE_PAGE_FORM', {
+        key,
+        data: { form: { ...this.form }, initialized: true }
+      })
+    },
+    restorePageCache() {
+      const key = this.pageCacheKey()
+      if (!key) return false
+      const cached = this.$store.state.tsb.devicePageFormCache[key]
+      if (!cached || !cached.initialized) return false
+      this.form = { ...cached.form }
+      return true
+    },
+    tryApplyWsPageData(clearInitFlag) {
+      const data = this.$store.state.tsb.pageData[this.pageDataKey()]
+      if (!data) return false
+      applyPartialFields(this.form, data, DISPLAY_FIELDS, createDefaultForm())
+      if (clearInitFlag && this.$store.state.tsb.initFromWs[this.pageDataKey()]) {
+        this.$store.commit('tsb/CLEAR_WS_INIT', { cmdType: PAGE_CMD, deviceSn: this.ownerDeviceSn })
+      }
+      this.savePageCache()
+      return true
+    },
+    refreshPageFromStore() {
+      if (this.tryApplyWsPageData(true)) return
+      if (this.restorePageCache()) return
+      this.initPageData()
+    },
+    initPageData() {
+      this.form = createDefaultForm()
+      if (this.canOperateDevice) {
+        this.syncDefaultParamsToDevice()
+      }
+      this.savePageCache()
+    },
+    syncDefaultParamsToDevice() {
+      tsbWebSocket.sendPageSync(PAGE_CMD, { ...this.form })
+    },
+    syncField(payload) {
+      if (!this.canOperateDevice) return
+      tsbWebSocket.sendPageSync(PAGE_CMD, payload)
+    },
+    onBaudRateChange(val) {
+      this.syncField({ baudRate: val })
+      this.savePageCache()
+    },
+    openQuery() {
+      this.$emit('navigate', 'query')
+    },
+    openPassthrough() {
+      this.$emit('navigate', 'passthrough')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.field-label { font-size: 13px; color: #606266; margin-bottom: 6px; }
+</style>

+ 113 - 0
ruoyi-ui/src/views/app-common/opw/passthrough.vue

@@ -0,0 +1,113 @@
+<template>
+  <div class="opw-passthrough">
+    <el-row :gutter="16">
+      <el-col :span="12">
+        <div class="panel-title">A口接收数据</div>
+        <el-input type="textarea" :rows="14" :value="form.aPortData || ''" readonly />
+      </el-col>
+      <el-col :span="12">
+        <div class="panel-title">B口接收数据</div>
+        <el-input type="textarea" :rows="14" :value="form.bPortData || ''" readonly />
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script>
+import tsbWebSocket from '@/utils/tsbWebSocket'
+import { OPW_CMD, applyPartialFields } from '@/utils/tsbOpwConfig'
+
+const PAGE_CMD = OPW_CMD.PASSTHROUGH
+const DISPLAY_FIELDS = ['aPortData', 'bPortData']
+
+function createDefaultForm() {
+  return { aPortData: '', bPortData: '' }
+}
+
+export default {
+  name: 'TsbOpwPassthrough',
+  props: {
+    boundDeviceSn: { type: [Number, String], default: null }
+  },
+  data() {
+    return {
+      form: createDefaultForm()
+    }
+  },
+  computed: {
+    ownerDeviceSn() {
+      return this.boundDeviceSn
+    },
+    canOperateDevice() {
+      const activeSn = this.$store.getters.tsbCurrentDeviceSn
+      return String(activeSn) === String(this.ownerDeviceSn)
+    }
+  },
+  watch: {
+    '$store.state.tsb.pageDataVersion'() {
+      this.tryApplyWsPageData(false)
+    },
+    '$store.state.tsb.deviceSwitchVersion'() {
+      if (String(this.$store.getters.tsbCurrentDeviceSn) === String(this.ownerDeviceSn)) {
+        this.refreshPageFromStore()
+      }
+    }
+  },
+  activated() {
+    this.refreshPageFromStore()
+  },
+  deactivated() {
+    this.savePageCache()
+  },
+  methods: {
+    pageCacheKey() {
+      return this.ownerDeviceSn != null ? `${this.ownerDeviceSn}::opw-passthrough` : null
+    },
+    pageDataKey() {
+      return this.ownerDeviceSn != null ? `${this.ownerDeviceSn}::${PAGE_CMD}` : PAGE_CMD
+    },
+    savePageCache() {
+      const key = this.pageCacheKey()
+      if (!key) return
+      this.$store.commit('tsb/SET_DEVICE_PAGE_FORM', {
+        key,
+        data: { form: { ...this.form }, initialized: true }
+      })
+    },
+    restorePageCache() {
+      const key = this.pageCacheKey()
+      if (!key) return false
+      const cached = this.$store.state.tsb.devicePageFormCache[key]
+      if (!cached || !cached.initialized) return false
+      this.form = { ...cached.form }
+      return true
+    },
+    tryApplyWsPageData(clearInitFlag) {
+      const data = this.$store.state.tsb.pageData[this.pageDataKey()]
+      if (!data) return false
+      applyPartialFields(this.form, data, DISPLAY_FIELDS, createDefaultForm())
+      if (clearInitFlag && this.$store.state.tsb.initFromWs[this.pageDataKey()]) {
+        this.$store.commit('tsb/CLEAR_WS_INIT', { cmdType: PAGE_CMD, deviceSn: this.ownerDeviceSn })
+      }
+      this.savePageCache()
+      return true
+    },
+    refreshPageFromStore() {
+      if (this.tryApplyWsPageData(true)) return
+      if (this.restorePageCache()) return
+      this.initPageData()
+    },
+    initPageData() {
+      this.form = createDefaultForm()
+      if (this.canOperateDevice) {
+        tsbWebSocket.sendPageSync(PAGE_CMD, { ...this.form })
+      }
+      this.savePageCache()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.panel-title { font-weight: 600; margin-bottom: 8px; }
+</style>

+ 203 - 0
ruoyi-ui/src/views/app-common/opw/query.vue

@@ -0,0 +1,203 @@
+<template>
+  <div class="opw-query">
+    <el-card shadow="never" class="mb16">
+      <el-row type="flex" align="middle" :gutter="12">
+        <el-col :span="8">
+          <span class="label">总罐数</span>
+          <span class="value">{{ displayTotalTanks }}</span>
+          <span class="unit">个</span>
+        </el-col>
+        <el-col :span="16" style="text-align: right">
+          <el-button type="primary" size="mini" @click="queryA">A口查询</el-button>
+          <el-button type="primary" size="mini" @click="queryB">B口查询</el-button>
+          <el-button type="primary" size="mini" @click="query485">485查询</el-button>
+        </el-col>
+      </el-row>
+    </el-card>
+
+    <el-table :data="form.tankList" border size="small" max-height="360" class="mb16">
+      <el-table-column
+        v-for="col in tankColumns"
+        :key="col.prop"
+        :prop="col.prop"
+        :label="col.label"
+        :min-width="col.width"
+        show-overflow-tooltip
+      />
+    </el-table>
+
+    <el-row type="flex" justify="space-between" align="middle">
+      <el-col :span="16">
+        <span class="label">执行结果:</span>
+        <el-tag size="mini" :type="resultTag(form.queryResult)">{{ form.queryResult || '-' }}</el-tag>
+      </el-col>
+      <el-col :span="8" style="text-align: right">
+        <el-button size="mini" @click="viewRawLog">查看原始日志</el-button>
+      </el-col>
+    </el-row>
+
+    <el-dialog title="原始日志" :visible.sync="logDialogVisible" width="520px" append-to-body>
+      <el-input type="textarea" :rows="12" :value="form.rawLog || ''" readonly />
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import tsbWebSocket from '@/utils/tsbWebSocket'
+import { OPW_CMD, OPW_BUTTON, OPW_TANK_COLUMNS, applyPartialFields } from '@/utils/tsbOpwConfig'
+
+const PAGE_CMD = OPW_CMD.QUERY
+const DISPLAY_FIELDS = ['totalTankCount', 'queryResult', 'rawLog', 'tankList']
+
+function createDefaultForm() {
+  return {
+    totalTankCount: null,
+    queryResult: '',
+    rawLog: '',
+    tankList: []
+  }
+}
+
+export default {
+  name: 'TsbOpwQuery',
+  props: {
+    boundDeviceSn: { type: [Number, String], default: null }
+  },
+  data() {
+    return {
+      tankColumns: OPW_TANK_COLUMNS,
+      form: createDefaultForm(),
+      logDialogVisible: false
+    }
+  },
+  computed: {
+    ownerDeviceSn() {
+      return this.boundDeviceSn
+    },
+    canOperateDevice() {
+      const activeSn = this.$store.getters.tsbCurrentDeviceSn
+      return String(activeSn) === String(this.ownerDeviceSn)
+    },
+    displayTotalTanks() {
+      return this.form.totalTankCount != null && this.form.totalTankCount !== '' ? this.form.totalTankCount : '-'
+    }
+  },
+  watch: {
+    '$store.state.tsb.pageDataVersion'() {
+      this.tryApplyWsPageData(false)
+    },
+    '$store.state.tsb.deviceSwitchVersion'() {
+      if (String(this.$store.getters.tsbCurrentDeviceSn) === String(this.ownerDeviceSn)) {
+        this.refreshPageFromStore()
+      }
+    }
+  },
+  activated() {
+    this.refreshPageFromStore()
+  },
+  deactivated() {
+    this.savePageCache()
+  },
+  methods: {
+    pageCacheKey() {
+      return this.ownerDeviceSn != null ? `${this.ownerDeviceSn}::opw-query` : null
+    },
+    pageDataKey() {
+      return this.ownerDeviceSn != null ? `${this.ownerDeviceSn}::${PAGE_CMD}` : PAGE_CMD
+    },
+    savePageCache() {
+      const key = this.pageCacheKey()
+      if (!key) return
+      this.$store.commit('tsb/SET_DEVICE_PAGE_FORM', {
+        key,
+        data: {
+          form: JSON.parse(JSON.stringify(this.form)),
+          logDialogVisible: this.logDialogVisible,
+          initialized: true
+        }
+      })
+    },
+    restorePageCache() {
+      const key = this.pageCacheKey()
+      if (!key) return false
+      const cached = this.$store.state.tsb.devicePageFormCache[key]
+      if (!cached || !cached.initialized) return false
+      this.form = JSON.parse(JSON.stringify(cached.form))
+      if (Object.prototype.hasOwnProperty.call(cached, 'logDialogVisible')) {
+        this.logDialogVisible = !!cached.logDialogVisible
+      }
+      return true
+    },
+    applyQueryData(data) {
+      if (!data) return
+      const defaults = createDefaultForm()
+      applyPartialFields(this.form, data, DISPLAY_FIELDS, defaults)
+      if (Object.prototype.hasOwnProperty.call(data, 'tankList') && Array.isArray(data.tankList)) {
+        this.form.tankList = data.tankList
+      }
+      if (data.buttonType === OPW_BUTTON.VIEW_RAW_LOG) {
+        this.logDialogVisible = true
+      }
+      this.savePageCache()
+    },
+    tryApplyWsPageData(clearInitFlag) {
+      const data = this.$store.state.tsb.pageData[this.pageDataKey()]
+      if (!data) return false
+      this.applyQueryData(data)
+      if (clearInitFlag && this.$store.state.tsb.initFromWs[this.pageDataKey()]) {
+        this.$store.commit('tsb/CLEAR_WS_INIT', { cmdType: PAGE_CMD, deviceSn: this.ownerDeviceSn })
+      }
+      return true
+    },
+    refreshPageFromStore() {
+      if (this.tryApplyWsPageData(true)) return
+      if (this.restorePageCache()) return
+      this.initPageData()
+    },
+    initPageData() {
+      this.form = createDefaultForm()
+      if (this.canOperateDevice) {
+        this.syncDefaultParamsToDevice()
+      }
+      this.savePageCache()
+    },
+    syncDefaultParamsToDevice() {
+      tsbWebSocket.sendPageSync(PAGE_CMD, {
+        totalTankCount: null,
+        queryResult: '',
+        rawLog: '',
+        tankList: []
+      })
+    },
+    sendButtonAction(buttonType) {
+      if (!this.canOperateDevice) return
+      tsbWebSocket.sendPageSync(PAGE_CMD, { buttonType })
+    },
+    queryA() {
+      this.sendButtonAction(OPW_BUTTON.QUERY_A)
+    },
+    queryB() {
+      this.sendButtonAction(OPW_BUTTON.QUERY_B)
+    },
+    query485() {
+      this.sendButtonAction(OPW_BUTTON.QUERY_485)
+    },
+    viewRawLog() {
+      this.logDialogVisible = true
+      this.savePageCache()
+      this.sendButtonAction(OPW_BUTTON.VIEW_RAW_LOG)
+    },
+    resultTag(text) {
+      if (!text) return 'info'
+      return text.indexOf('成功') >= 0 ? 'success' : 'danger'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mb16 { margin-bottom: 16px; }
+.label { color: #909399; margin-right: 4px; }
+.value { font-weight: 600; margin-right: 4px; }
+.unit { color: #606266; }
+</style>

+ 1 - 1
ruoyi-ui/src/views/tsb/device-shell/deviceMenus.js

@@ -11,7 +11,7 @@ export const DEVICE_TAB_NAMES = [
 export const DEVICE_DEMOS = [
   [
     { name: '报税口', route: 'tax', icon: 'money', color: '#007AFF', enabled: true },
-    { name: '液位仪', route: 'opw', icon: 'slider', color: '#4CAF50', enabled: false },
+    { name: '液位仪', route: 'opw', icon: 'slider', color: '#4CAF50', enabled: true },
     { name: '提枪信号', route: 'raise', icon: 'radio', color: '#F44336', enabled: false },
     { name: '编码器', route: 'coder', icon: 'code', color: '#FF9800', enabled: false },
     { name: '简易示波器', route: 'oscilloscope', icon: 'chart', color: '#FFAB91', enabled: false },

+ 3 - 0
ruoyi-ui/src/views/tsb/device-shell/home.vue

@@ -80,6 +80,9 @@ export default {
         return
       }
       this.$store.commit('tsb/SET_DEVICE_PANEL', { deviceSn: sn, panel: demo.route })
+      if (demo.route === 'opw') {
+        this.$store.commit('tsb/SET_OPW_SUB_PAGE', { deviceSn: sn, subPage: 'main' })
+      }
       saveTsbDeviceSession(buildDeviceSession(
         this.$store.getters.tsbCurrentDevice,
         this.$store.getters.tsbOpenedDevices,

+ 26 - 4
ruoyi-ui/src/views/tsb/device-shell/index.vue

@@ -27,7 +27,7 @@
       </div>
 
       <div v-if="!isHomePage" class="sub-toolbar">
-        <el-button type="text" icon="el-icon-back" @click="goDeviceHome">返回功能菜单</el-button>
+        <el-button type="text" icon="el-icon-back" @click="goBack">返回</el-button>
         <span class="sub-title">{{ subPageTitle }}</span>
         <span class="sub-sn">设备 SN:{{ currentDeviceSn }}</span>
       </div>
@@ -45,20 +45,24 @@ import { ensureWorkspaceTag } from '@/utils/tsbWorkspaceNav'
 import { buildDeviceSession, clearTsbDeviceSession, hasTsbDeviceSession, saveTsbDeviceSession } from '@/utils/tsbDeviceSession'
 import DeviceHome from './home'
 import TaxPage from '@/views/app-common/tax/index'
+import OpwPage from '@/views/app-common/opw/index'
+import { OPW_SUB_PAGE_TITLE } from '@/utils/tsbOpwConfig'
 
 const PANEL_COMPONENTS = {
   home: DeviceHome,
-  tax: TaxPage
+  tax: TaxPage,
+  opw: OpwPage
 }
 
 const PANEL_TITLES = {
   home: '设备功能',
-  tax: '报税口'
+  tax: '报税口',
+  opw: '液位仪'
 }
 
 export default {
   name: 'TsbDeviceShell',
-  components: { DeviceHome, TaxPage },
+  components: { DeviceHome, TaxPage, OpwPage },
   computed: {
     openedDevices() {
       return this.$store.getters.tsbOpenedDevices || []
@@ -73,6 +77,10 @@ export default {
       return this.currentPanel === 'home'
     },
     subPageTitle() {
+      if (this.currentPanel === 'opw') {
+        const sub = this.$store.getters.tsbOpwSubPage
+        return OPW_SUB_PAGE_TITLE[sub] || PANEL_TITLES.opw
+      }
       return PANEL_TITLES[this.currentPanel] || ''
     },
     panelComponent() {
@@ -145,6 +153,20 @@ export default {
         tsbWebSocket.switchToDevice(next).catch(() => {})
       }
     },
+    goBack() {
+      const sn = this.currentDeviceSn
+      if (sn == null) {
+        return
+      }
+      if (this.currentPanel === 'opw') {
+        const subPage = this.$store.getters.tsbOpwSubPage
+        if (subPage && subPage !== 'main') {
+          this.$store.commit('tsb/SET_OPW_SUB_PAGE', { deviceSn: sn, subPage: 'main' })
+          return
+        }
+      }
+      this.goDeviceHome()
+    },
     goDeviceHome() {
       const sn = this.currentDeviceSn
       if (sn != null) {