Просмотр исходного кода

feat: 完成V4协议适配与卡号设置功能开发

1.  新增V4版本DTU与节点通信协议支持,实现批量卡号读写与设置功能
2.  重构前端页面,将LED调试页面改为卡号设置测试页面
3.  添加环境传感器图表样式优化与终端列表滚动优化
4.  新增rkssh工具用于RK3562设备SSH服务部署
5.  更新文档与配置文件,完善项目部署说明
wenhongquan 2 дней назад
Родитель
Сommit
8709f1c7ae
55 измененных файлов с 1869 добавлено и 256 удалено
  1. 62 1
      AGENTS.md
  2. BIN
      DTU和节点之间协议 V4(1).docx
  3. BIN
      backend/__pycache__/app.cpython-310.pyc
  4. 268 28
      backend/app.py
  5. BIN
      backend/modules/__pycache__/dht11_sensor.cpython-310.pyc
  6. BIN
      backend/modules/__pycache__/modbus_rtu.cpython-310.pyc
  7. 97 0
      backend/modules/modbus_rtu.py
  8. 9 0
      backend/mqtt_config.json
  9. 0 0
      backend/static/assets/About-16c37bd6.js
  10. 0 1
      backend/static/assets/About-2016965a.css
  11. 0 0
      backend/static/assets/About-98922cff.js
  12. 1 0
      backend/static/assets/About-a3a5268b.css
  13. 0 0
      backend/static/assets/AlarmStatus-a87db74d.js
  14. 1 0
      backend/static/assets/AlarmStatus-d23d9680.css
  15. 1 0
      backend/static/assets/EnvSensor-7e1ba4be.css
  16. 0 0
      backend/static/assets/EnvSensor-ce44d362.js
  17. 0 0
      backend/static/assets/JumperStatus-27af676c.css
  18. 0 0
      backend/static/assets/JumperStatus-4cbd022b.js
  19. 0 0
      backend/static/assets/LedDebug-ab46e20b.js
  20. 1 0
      backend/static/assets/LedDebug-d6c41b17.css
  21. 1 0
      backend/static/assets/NetworkConfig-47a0fff5.css
  22. 0 1
      backend/static/assets/NetworkConfig-6f5efcd1.css
  23. 0 0
      backend/static/assets/NetworkConfig-a54a5ccb.js
  24. 0 0
      backend/static/assets/NetworkConfig-aca027d0.js
  25. 1 0
      backend/static/assets/OTAUpgrade-0dadd681.css
  26. 0 0
      backend/static/assets/OTAUpgrade-676eff63.js
  27. 0 0
      backend/static/assets/PanelStatus-345febed.css
  28. 0 0
      backend/static/assets/PanelStatus-66a71c0a.js
  29. 0 0
      backend/static/assets/PortStatus-bfb07be0.css
  30. 2 0
      backend/static/assets/PortStatus-e8439e28.js
  31. 0 0
      backend/static/assets/RealTimeStatus-2b7e18f7.css
  32. 0 0
      backend/static/assets/RealTimeStatus-53aebb00.js
  33. 0 0
      backend/static/assets/RealTimeStatus-7cb17239.js
  34. 0 0
      backend/static/assets/RealTimeStatus-d00a8416.css
  35. 13 0
      backend/static/assets/index-12f90946.js
  36. 4 0
      backend/static/assets/index-29b7bbe1.js
  37. 0 4
      backend/static/assets/index-af4a73a6.js
  38. 0 0
      backend/static/assets/index-d2e45fca.css
  39. 0 0
      backend/static/assets/index-ef806d81.css
  40. 2 2
      backend/static/index.html
  41. 699 0
      backend/static/vite.svg
  42. 32 0
      frontend/package-lock.json
  43. 2 2
      frontend/src/App.vue
  44. 1 0
      frontend/src/api/apiService.js
  45. 1 1
      frontend/src/router/index.js
  46. 57 13
      frontend/src/views/AlarmStatus.vue
  47. 4 1
      frontend/src/views/EnvSensor.vue
  48. 167 161
      frontend/src/views/LedDebug.vue
  49. 44 22
      frontend/src/views/PortStatus.vue
  50. 40 19
      frontend/src/views/RealTimeStatus.vue
  51. 8 0
      tools/rkssh/go.mod
  52. 6 0
      tools/rkssh/go.sum
  53. 329 0
      tools/rkssh/main.go
  54. 5 0
      tools/rkssh/rkssh.rc
  55. 11 0
      tools/rkssh/start.sh

+ 62 - 1
AGENTS.md

@@ -18,6 +18,28 @@
   - `mosquitto.service` → 本地 MQTT Broker(:1883)
   - `nginx.service`、`mysql.service` 等
 
+## 第二块板:RK3562(192.168.199.235)
+
+> ⚠️ 这块板**不是 Ubuntu/Linux,而是 Android 13**(`rk3562_t-userdebug`,内核 5.10.157,Toybox shell)。
+> 没有 `/etc/os-release`、`apt`、`systemctl`、`bash`,也没有任何 sshd/dropbear。**本仓库的 `dzxj_dtu`(systemd+Python+Flask)无法直接跑在 Android 上**;要跑 DTU 应用需刷厂商 Ubuntu 镜像。
+
+- **系统**:Android 13,userdebug(`getprop ro.build.flavor` = `rk3562_t-userdebug`),SELinux = Permissive。
+- **网络**:eth1 = `192.168.199.235`。
+- **adb**:USB 连接,`adb devices` 可见;`adb root` 可切 root(重启后失效,需重跑)。
+- **SSH(已配好)**:`ssh root@192.168.199.235`,密码 `123456`,登录即 root shell,**开机自启**。
+  - Android 自带无 sshd,官方 Android dropbear 构建把密码认证编译掉了(`DROPBEAR_SVR_PASSWORD_AUTH 0`,因 Android 无 `crypt()`),所以自建了一个 **Go 静态 SSH 服务 `rkssh`**(密码认证 + PTY + exec)。
+  - 设备上文件:
+    - `/data/dropbear/rkssh` —— 静态 aarch64 二进制(`-addr -user -pass -hostkey -shell` 参数)
+    - `/data/dropbear/go_hostkey` —— ed25519 主机密钥(首次运行自动生成)
+    - `/data/dropbear/start.sh` —— 手动启动脚本
+    - `/system/etc/init/rkssh.rc` —— init 服务,开机自启(`class main`, `user root`)
+    - 日志:`/data/dropbear/rkssh.log`(手动启动时)
+  - 持久化说明:`/system` 通过 `adb remount`(overlayfs)变可写后才写入 rc;overlay 重启后仍生效(已实测重启后 22 端口自动监听)。
+  - 源码(Go)在本仓库 `tools/rkssh/`(main.go/go.mod/go.sum/rkssh.rc/start.sh),构建:
+    `cd tools/rkssh && GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o rkssh-arm64 .`
+    然后 `adb push rkssh-arm64 /data/dropbear/rkssh && adb shell chmod 755 /data/dropbear/rkssh`。
+- **改密码/端口**:改 `/system/etc/init/rkssh.rc` 里的 `-pass`/`-addr`,然后 `adb reboot`;或临时 `pkill rkssh` 后用 `/data/dropbear/start.sh` 重启。
+
 ## 远程访问方式
 
 ### 方式 1:FRP stcp 映射 SSH(推荐)
@@ -149,9 +171,48 @@ journalctl -u dzxj_dtu | grep -E 'DHT11|环境传感器'
 - MODBUS-RTU 协议实现在 `backend/modules/modbus_rtu.py`;串口封装在 `backend/modules/serial_port.py`。
 - 注意:硬件通信固定 6 字节(12位) 卡号,MQTT 边界拼成 8 字节(16位) `jumper_uid`(前 4 位 DTU 号 + 后 12 位卡号),适配逻辑在 `app.py` 的 `_to_mqtt_jumper_uid` / `_from_mqtt_jumper_uid`。
 
+### V4 协议(20260813)重点
+
+协议文档:`DTU和节点之间协议  V4(1).docx`。寄存器地址:
+
+| 地址  | 内容         | 说明                                   |
+| ----- | ------------ | -------------------------------------- |
+| 0x0000| 网络通断     | **保留不用**(已去除)                 |
+| 0x0001| 24 个 RGB 灯 | **保留不用**(灯由配线架自己控制)     |
+| 0x0002| 批量卡号读   | 0x03 读 72 寄存器 = 144 字节,24 路×6 字节 |
+| 0x0003| 设置卡号     | 0x10 写 144 字节,24 路×6 字节         |
+
+**设置卡号协议(非标准 MODBUS 0x10,重点!)**:
+
+- 帧格式:`[addr][0x10][0x00][0x03][0x00][0x90][144字节数据][CRC_L][CRC_H]`
+- **无 byte count 字段**(与标准 MODBUS 0x10 不同),数量字段 `0x0090=144` 直接表示字节数
+- 24 路卡号同时发送,每路 6 字节;无卡标记 = `FF FF FF FF FF FF`
+- 响应帧:`[addr][0x10][0x00][0x03][0x00][0x90][CRC_L][CRC_H]`(标准 0x10 响应,8 字节)
+- 实现:`modbus_rtu.py` 的 `ModbusRTUClient.set_card_mapping(device_address, card_mappings)`
+- HTTP API:`POST /api/modbus/set_card_mapping`(支持完整 24 路或单端口模式)
+- MQTT 命令:`SYNC_PORT_MAPPING` / `SYNC_ALL_MAPPING` 更新本地期望后**自动下发硬件**
+
+**同步卡号流程**:
+
+1. MQTT 收到 `SYNC_PORT_MAPPING`/`SYNC_ALL_MAPPING` → 更新 `port_state` 期望值 + 持久化 `dtu_config.json`
+2. 构建 24 路 card_mappings:目标端口填新卡号,其余从 `port_mappings` 读取现有期望(保留),未配置填 `0xFF`
+3. 调用 `set_card_mapping` 通过串口下发到配线架硬件
+4. 下发失败返回 error_code=1008
+
+**LED 控制变化**:
+
+- V4 起 LED 由配线架自控,DTU 轮询中**不再自动下发 LED 状态**(已移除 `set_rgb_led` 自动同步逻辑)
+- 原「LED 调试」页面已改为**「卡号设置测试」**页面(路由 `/led-debug`,组件 `LedDebug.vue`)
+- 页面功能:选终端 + 选端口 + 输入 12 位 hex 卡号 + 设置/清除 + 读卡验证
+
 ## 部署与更新
 
-- 代码在 `/root/dzxj_dtu/`,后端在 `backend/`,前端构建产物在 `backend/static/`。
+- 代码在 `/root/dzxj_dtu/`,后端在 `backend/`,前端构建产物在 `frontend/dist/`。
+- **前端静态文件服务位置(重要,别传错!)**:
+  - Flask 5001:`app.py` 的 `STATIC_FOLDER = frontend/dist`,直接服务该目录
+  - nginx 80:`root /var/www/html`,服务该目录
+  - `backend/static/` 目录**不被任何服务使用**,传到这里等于白传
+  - 部署前端时必须同时覆盖 `frontend/dist/` 和 `/var/www/html/` 两个目录
 - 服务使用 venv:`/root/dzxj_dtu/venv/bin/python`(依赖见 `backend/requirements.txt`,注意 gpiod 是 pip 1.5.4)。
 - 常用操作:
   ```bash

BIN
DTU和节点之间协议 V4(1).docx


BIN
backend/__pycache__/app.cpython-310.pyc


+ 268 - 28
backend/app.py

@@ -271,6 +271,20 @@ def _to_internal_uid_lenient(value):
     return None
 
 
+def _build_card_mappings_for_panel(panel_id):
+    """从 dtu_config port_mappings 构建该面板 24 路卡号映射,用于 V4 下发硬件。
+
+    返回: {port_id(1-24): 'hex12' or None}
+    未配置/非法的端口返回 None(set_card_mapping 内部填 0xFF*6 无卡标记)。
+    """
+    mappings = dtu_config.get('port_mappings', {}).get(panel_id, {})
+    result = {}
+    for port_id in range(1, 25):
+        uid = mappings.get(str(port_id))
+        result[port_id] = _to_internal_uid_lenient(uid)  # 非法/空 -> None
+    return result
+
+
 def get_sorted_panels():
     """按 position 排序返回 panel 列表。"""
     return sorted(panel_config.items(), key=lambda x: x[1].get('position', 0))
@@ -1591,6 +1605,53 @@ def dtu_handle_control(topic, payload):
             dtu_config.setdefault('port_mappings', {}).setdefault(panel_target, {})[str(port_id)] = expected_uid
             save_dtu_config()
 
+            # V4: 下发卡号到配线架硬件(24路同时发送;保留其他端口真实硬件值,避免误擦)
+            device_address = panel_config[panel_target].get('address', 1)
+            # 策略同 HTTP 单端口模式:硬件读基底 → dtu_config 覆盖 → 目标端口覆盖
+            card_mappings = {i: None for i in range(1, 25)}
+            try:
+                hw_read = modbus_client.read_antenna_card(device_address, timeout=2.0)
+                if 'error' not in hw_read:
+                    for card in hw_read.get('cards', []):
+                        ant = card.get('antenna')
+                        cs = card.get('card_str') or card.get('uid')
+                        if ant and cs and str(cs).lower() != 'ffffffffffff':
+                            cs_clean = str(cs).strip().upper().replace(':', '').replace(' ', '')
+                            if len(cs_clean) == 12 and all(c in _JUMPER_HEX for c in cs_clean):
+                                card_mappings[int(ant)] = cs_clean
+            except Exception as e:
+                logger.warning(f"SYNC_PORT_MAPPING 读取硬件基底失败: {e}")
+            # dtu_config 期望值覆盖(平台下发过的映射是权威)
+            dtu_base = _build_card_mappings_for_panel(panel_target)
+            for ant, v in dtu_base.items():
+                if v is not None:
+                    card_mappings[ant] = v
+            # 目标端口覆盖(本次要设置/清除的值)
+            try:
+                card_mappings[int(port_id)] = expected_uid
+            except (ValueError, TypeError):
+                response_payload['payload']['success'] = False
+                response_payload['payload']['error_code'] = 1006
+                response_payload['payload']['error_message'] = f"port_id 非法: {port_id}"
+                mqtt_client.publish(topic_response_dtu, json.dumps(response_payload), qos=1)
+                return
+            try:
+                hw_result = modbus_client.set_card_mapping(device_address, card_mappings, timeout=2.0)
+                if 'error' in hw_result:
+                    response_payload['payload']['success'] = False
+                    response_payload['payload']['error_code'] = 1008
+                    response_payload['payload']['error_message'] = f"卡号下发硬件失败: {hw_result['error']}"
+                    mqtt_client.publish(topic_response_dtu, json.dumps(response_payload), qos=1)
+                    return
+                response_payload['payload']['result'] = {'hardware': hw_result}
+                logger.info(f"SYNC_PORT_MAPPING 面板{panel_target}端口{port_id} 卡号已下发硬件: {expected_uid}")
+            except Exception as e:
+                response_payload['payload']['success'] = False
+                response_payload['payload']['error_code'] = 1008
+                response_payload['payload']['error_message'] = f"卡号下发异常: {e}"
+                mqtt_client.publish(topic_response_dtu, json.dumps(response_payload), qos=1)
+                return
+
         elif command == 'SYNC_ALL_MAPPING':
             # 批量同步期望映射
             mappings = params.get('mappings', [])
@@ -1625,6 +1686,32 @@ def dtu_handle_control(topic, payload):
                 mqtt_client.publish(topic_response_dtu, json.dumps(response_payload), qos=1)
                 return
 
+            # V4: 对涉及的面板逐个下发卡号到硬件
+            affected_panels = set()
+            for mapping in mappings:
+                pid = _resolve_panel_id(mapping.get('panel_id'))
+                if pid:
+                    affected_panels.add(pid)
+            hw_results = {}
+            hw_errors = {}
+            for pid in affected_panels:
+                device_address = panel_config[pid].get('address', 1)
+                card_mappings = _build_card_mappings_for_panel(pid)
+                try:
+                    hw_result = modbus_client.set_card_mapping(device_address, card_mappings, timeout=2.0)
+                    if 'error' in hw_result:
+                        hw_errors[pid] = hw_result['error']
+                    else:
+                        hw_results[pid] = 'ok'
+                        logger.info(f"SYNC_ALL_MAPPING 面板{pid} 卡号已下发硬件")
+                except Exception as e:
+                    hw_errors[pid] = str(e)
+            if hw_errors:
+                response_payload['payload']['success'] = False
+                response_payload['payload']['error_code'] = 1008
+                response_payload['payload']['error_message'] = f"部分面板卡号下发失败: {hw_errors}"
+            response_payload['payload']['result'] = {'hardware': {'success': hw_results, 'errors': hw_errors}}
+
         elif command == 'REBOOT':
             # 重启DTU: 1s 延迟后执行 reboot
             import subprocess
@@ -3039,6 +3126,154 @@ def modbus_read_all_cards():
         return jsonify({'success': False, 'message': str(e)}), 500
 
 
+@app.route('/api/modbus/set_card_mapping', methods=['POST'])
+def modbus_set_card_mapping():
+    """V4: 设置24路天线卡号到配线架硬件(0x10 写 0x0003,144字节)
+
+    请求参数:
+    {
+        "device_address": 1,
+        "card_mappings": {"1": "aabbccddeeff", "2": null, ...},  // 完整模式:24路映射
+        // 或单端口模式:
+        "port_id": 1,
+        "card_uid": "aabbccddeeff",   // null/空表示清除该端口卡号
+        "panel_id": "PANEL_dtu_001_2", // 单端口模式下用于读取其余端口期望卡号(可选)
+        "timeout": 2.0
+    }
+    返回:
+    {
+        "success": true,
+        "data": {"device_address": 1, "cards_written": {...}, "raw_data": "..."}
+    }
+    """
+    try:
+        data = request.json or {}
+        device_address = data.get('device_address', 1)
+        timeout = data.get('timeout')
+
+        if not (isinstance(serial_client.get_status(), dict) and serial_client.get_status().get("connected", False)):
+            return jsonify({'success': False, 'message': '串口未连接'}), 400
+
+        card_mappings = data.get('card_mappings')
+        if card_mappings is None:
+            # 单端口模式:构建 24 路数据,避免擦掉其他端口
+            # 策略: 先读硬件当前值做基底 → 再用 dtu_config 期望值覆盖 → 最后覆盖目标端口
+            # 这样 dtu_config 没记录但硬件已有卡号的端口(例如修复前设置的)不会被误清为 0xFF
+            port_id = data.get('port_id')
+            card_uid = data.get('card_uid')
+            # panel_id 解析:前端传的优先,否则用 device_address 反查 panel_config(LedDebug.vue 默认只传 address)
+            panel_id_from_req = _resolve_panel_id(data.get('panel_id'))
+            panel_id_from_addr = None
+            for pid, cfg in panel_config.items():
+                if cfg.get('address') == device_address:
+                    panel_id_from_addr = pid
+                    break
+            panel_id = panel_id_from_req or panel_id_from_addr
+
+            if port_id is None:
+                return jsonify({'success': False, 'message': '缺少 port_id 或 card_mappings'}), 400
+
+            # 第一步:读硬件当前 24 路值,作为默认基底(防止擦掉其他端口硬件里真实存在的卡号)
+            card_mappings = {i: None for i in range(1, 25)}
+            try:
+                hw_read = modbus_client.read_antenna_card(device_address, timeout=2.0)
+                if 'error' not in hw_read:
+                    for card in hw_read.get('cards', []):
+                        ant = card.get('antenna')
+                        cs = card.get('card_str') or card.get('uid')
+                        if ant and cs and str(cs).lower() != 'ffffffffffff':
+                            cs_clean = str(cs).strip().upper().replace(':', '').replace(' ', '')
+                            if len(cs_clean) == 12 and all(c in _JUMPER_HEX for c in cs_clean):
+                                card_mappings[int(ant)] = cs_clean
+                    logger.debug(f"单端口模式已用硬件读值初始化基底: {sum(1 for v in card_mappings.values() if v)} 路有卡")
+            except Exception as e:
+                logger.warning(f"单端口模式读取硬件基底失败(将使用 dtu_config 兜底): {e}")
+
+            # 第二步:用 dtu_config 期望值覆盖(平台下发的才是权威,硬件基底只补缺失项)
+            # 注意:dtu_config 中已有但硬件读不到的端口(比如刚写入、读延迟),也必须强制合入,防止回擦
+            if panel_id:
+                dtu_base = _build_card_mappings_for_panel(panel_id)
+                for ant, v in dtu_base.items():
+                    if v is not None:
+                        card_mappings[ant] = v
+                logger.debug(f"单端口模式 panel={panel_id} dtu_config 期望合入: {sum(1 for v in dtu_base.values() if v)} 路")
+
+            # 第三步:最后覆盖目标端口(用户要设置/清除的值优先级最高)
+            if card_uid:
+                uid = str(card_uid).strip().upper().replace(':', '').replace(' ', '')
+                if len(uid) != 12 or not all(c in _JUMPER_HEX for c in uid):
+                    return jsonify({'success': False, 'message': f'卡号必须是12位十六进制: {card_uid}'}), 400
+            else:
+                uid = None
+            try:
+                card_mappings[int(port_id)] = uid
+            except (ValueError, TypeError):
+                return jsonify({'success': False, 'message': f'port_id 非法: {port_id}'}), 400
+
+        # 转换 key 为 int
+        normalized = {}
+        for k, v in card_mappings.items():
+            try:
+                port = int(k)
+            except (ValueError, TypeError):
+                return jsonify({'success': False, 'message': f'端口编号非法: {k}'}), 400
+            if not (1 <= port <= 24):
+                return jsonify({'success': False, 'message': f'端口编号必须在1-24: {port}'}), 400
+            normalized[port] = v
+
+        result = modbus_client.set_card_mapping(device_address, normalized, timeout=timeout)
+
+        if 'error' in result:
+            return jsonify({
+                'success': False,
+                'message': result['error'],
+                'raw_data': result.get('raw_data', '')
+            }), 400
+
+        # 硬件写入成功:同步更新本地 port_state 期望值 + 持久化 dtu_config
+        # 1. 先通过 device_address 反查 panel_id
+        panel_id_by_addr = None
+        for pid, cfg in panel_config.items():
+            if cfg.get('address') == device_address:
+                panel_id_by_addr = pid
+                break
+        # 单端口模式下若传入了 panel_id,优先用传入值
+        panel_target = _resolve_panel_id(data.get('panel_id')) or panel_id_by_addr
+
+        warnings = []
+        cards_written = result.get('cards_written', {})
+        if panel_target:
+            port_state.setdefault(panel_target, {})
+            for port_id, uid in cards_written.items():
+                port_id = int(port_id)
+                port_state[panel_target].setdefault(port_id, {
+                    'last_uid': None, 'expected_uid': None, 'alarm_count': 0
+                })
+                # 规范化为 12 位大写 hex 或 None
+                if uid:
+                    clean_uid = str(uid).strip().upper().replace(':', '').replace(' ', '')
+                    if len(clean_uid) == 12 and all(c in _JUMPER_HEX for c in clean_uid):
+                        expected_uid = clean_uid
+                    else:
+                        expected_uid = None
+                else:
+                    expected_uid = None
+                port_state[panel_target][port_id]['expected_uid'] = expected_uid
+                # 持久化到 dtu_config.json(内部 12 位)
+                dtu_config.setdefault('port_mappings', {}).setdefault(panel_target, {})
+                dtu_config['port_mappings'][panel_target][str(port_id)] = expected_uid
+            save_dtu_config()
+            logger.info(f"卡号设置页 面板{panel_target} 已同步本地期望值: {len(cards_written)} 路")
+        else:
+            warnings.append(f"未找到 device_address={device_address} 对应的面板,本地期望值未更新(仅硬件已生效)")
+
+        return jsonify({'success': True, 'data': result, 'warnings': warnings})
+
+    except Exception as e:
+        logger.error(f'设置卡号失败: {str(e)}')
+        return jsonify({'success': False, 'message': str(e)}), 500
+
+
 @app.route('/api/modbus/read_registers', methods=['POST'])
 def modbus_read_registers():
     """读取保持寄存器
@@ -4353,11 +4588,43 @@ def dht11_data_callback(temperature, humidity):
 
     将读取到的温湿度更新到环境传感器数据区,并通过 MQTT 上报。
     保留已有的 dtu_temperature(主板温度),仅更新环境温湿度字段。
+    若外部未上报 dtu_temperature,则从本地 sysfs 读取(与 /api/sensor/env 回退逻辑一致),
+    确保历史记录中 DTU 温度字段有值。
     """
     try:
         sensor_update_time = int(time.time() * 1000)
         dtu_temperature = env_sensor_data.get('dtu_temperature')
 
+        # DTU 主板温度回退:外部未上报时从 sysfs 本地读取,并回填 env_sensor_data
+        # 否则历史记录 dtu_temperature 会一直是 None(/api/sensor/env 有回退但历史写入没有)
+        if dtu_temperature is None:
+            try:
+                import psutil
+                temps = psutil.sensors_temperatures(fahrenheit=False) or {}
+                for chip_name, entries in temps.items():
+                    for entry in entries:
+                        if entry.current and entry.current > 0:
+                            dtu_temperature = round(entry.current, 1)
+                            break
+                    if dtu_temperature is not None:
+                        break
+            except Exception:
+                pass
+            if dtu_temperature is None:
+                import glob as _glob
+                for tz in sorted(_glob.glob('/sys/class/thermal/thermal_zone*/temp')):
+                    try:
+                        with open(tz) as f:
+                            v = int(f.read().strip())
+                            if v > 0:
+                                dtu_temperature = round(v / 1000.0, 1)
+                                break
+                    except Exception:
+                        continue
+            # 回填到 env_sensor_data,避免每次回调都重新读 sysfs
+            if dtu_temperature is not None:
+                env_sensor_data['dtu_temperature'] = dtu_temperature
+
         update_env_sensor_data_full(temperature, humidity, dtu_temperature, sensor_update_time)
 
         # 立即通过 MQTT 上报环境传感器数据
@@ -4566,30 +4833,7 @@ if __name__ == '__main__':
                                     port_status = 'DISCONNECTED'
                                 panel_ports.append({'port_id': port_id, 'status': port_status, 'jumper_uid': uid or None})
 
-                                # LED 自动同步(依据端口状态;仅在状态变化时下发,避免刷屏)
-                                # 色码对应协议 6.5: 0=灭 1=红 2=绿 3=蓝
-                                if port_status == 'CONNECTED':
-                                    desired_led = 2  # 绿:正确连接
-                                elif port_status == 'ILLEGAL':
-                                    desired_led = 1  # 红:错卡
-                                elif port_status == 'DISCONNECTED' and ps_exp:
-                                    desired_led = 3  # 蓝:应有卡但未读到 (ILLEGAL_DISCONNECT)
-                                else:
-                                    desired_led = 0  # 灭:未连接未设置(无期望+无卡)
-                                if ps.get('last_led') != desired_led:
-                                    try:
-                                        led_res = modbus_client.set_rgb_led(addr, port_id, desired_led, timeout=0.5)
-                                        if 'error' not in led_res:
-                                            ps['last_led'] = desired_led
-                                            # 同步更新 led_states 供 Web 展示
-                                            if addr not in led_states:
-                                                led_states[addr] = {}
-                                            led_states[addr][port_id] = desired_led
-                                            logger.info(f"LED 同步 panel={panel_id} port={port_id} color={desired_led}")
-                                        else:
-                                            logger.warning(f"LED 同步失败 panel={panel_id} port={port_id}: {led_res.get('error')}")
-                                    except Exception as e:
-                                        logger.warning(f"LED 同步异常 panel={panel_id} port={port_id}: {e}")
+                                # V4: LED 由配线架自控,DTU 不再自动下发灯状态
 
                             online += 1
                             if cfg.get('status') == 'offline' or panel_fail_count.get(panel_id, 0) > 0 or panel_assign_pending.get(panel_id):
@@ -4597,10 +4841,6 @@ if __name__ == '__main__':
                                 panel_fail_count[panel_id] = 0
                                 panel_assign_pending.pop(panel_id, None)
                                 logger.info(f"面板 {panel_id}(地址={addr}) 恢复在线")
-                                # 设备上线默认全蓝灯,清空 last_led 缓存,强制下一轮全量重发 LED
-                                # (配置的按状态亮,未配置的灭灯),覆盖硬件默认状态
-                                for pid, ps in port_state.get(panel_id, {}).items():
-                                    ps['last_led'] = None
                             device_last_seen[panel_id] = time.time()
                             device_last_seen[cfg.get('panel_uid', '')] = time.time()
                             dtu_publish_panel_status(panel_id, addr, panel_ports)

BIN
backend/modules/__pycache__/dht11_sensor.cpython-310.pyc


BIN
backend/modules/__pycache__/modbus_rtu.cpython-310.pyc


+ 97 - 0
backend/modules/modbus_rtu.py

@@ -325,6 +325,15 @@ NO_TAG_HEX = 'ffffffffffff'
 BULK_BYTE_COUNT = 0x90          # 144,Modbus 响应字节计数字段
 BULK_RESPONSE_LEN = 149         # addr(1)+fc(1)+bytecount(1)+data(144)+crc(2)
 
+# V4 设置卡号协议常量(非标准 MODBUS 0x10)
+# 协议帧: [addr][0x10][0x00][0x03][0x00][0x90][144字节数据][CRC_L][CRC_H]
+# 注意: 与标准 MODBUS 0x10 不同,此处省略 byte count 字段,数量字段 0x0090 直接表示字节数(144)
+SET_CARD_REG_ADDR = 0x0003      # 设置卡号起始寄存器
+SET_CARD_QUANTITY = 0x0090      # 数量字段 = 144(字节数,非寄存器数)
+SET_CARD_DATA_BYTES = 144       # 24 路 × 6 字节
+SET_CARD_REQUEST_LEN = 152      # addr(1)+fc(1)+start(2)+qty(2)+data(144)+crc(2)
+SET_CARD_RESPONSE_LEN = 8       # addr(1)+fc(1)+start(2)+qty(2)+crc(2)
+
 # 天线地址映射表(天线编号 -> Modbus寄存器地址)
 # V4: 所有天线共享寄存器 0x0002,由 read_all_antenna_cards 一次批量读出
 ANTENNA_ADDRESSES = {i: CARD_REG_ADDR for i in range(1, 25)}
@@ -531,6 +540,94 @@ class ModbusRTUClient:
             **base
         }
 
+    def set_card_mapping(self, device_address: int, card_mappings: dict, timeout: float = None) -> dict:
+        """V4: 设置24路天线卡号(0x10 写 0x0003,144字节,非标准MODBUS无bytecount)。
+
+        协议帧(发送): [addr][0x10][0x00][0x03][0x00][0x90][144字节数据][CRC_L][CRC_H]
+        协议帧(响应): [addr][0x10][0x00][0x03][0x00][0x90][CRC_L][CRC_H]
+
+        Args:
+            device_address: 从机地址
+            card_mappings: {port_id(1-24): 'hex12' or None}
+                           未提供或 None 的端口填 0xFF*6(无卡标记)
+        Returns:
+            {'success': True, 'device_address', 'raw_data', 'cards_written'} 或
+            {'error': ...}
+        """
+        timeout = timeout or self.default_timeout
+
+        if not self.serial.ser:
+            return {"error": "serial not connected"}
+
+        # 构建 24 路 × 6 字节 = 144 字节数据
+        data_bytes = b''
+        written = {}
+        for ant in range(1, ANTENNA_COUNT + 1):
+            hex_str = card_mappings.get(ant)
+            if hex_str:
+                hex_str = str(hex_str).strip().lower().replace(':', '').replace(' ', '')
+                try:
+                    tag = bytes.fromhex(hex_str)
+                except ValueError:
+                    return {"error": f"port {ant} 卡号十六进制非法: {hex_str!r}"}
+                if len(tag) != BYTES_PER_TAG:
+                    return {"error": f"port {ant} 卡号必须 {BYTES_PER_TAG} 字节,当前 {len(tag)} 字节: {hex_str!r}"}
+                data_bytes += tag
+                written[ant] = hex_str
+            else:
+                data_bytes += NO_TAG_BYTES
+                written[ant] = None
+
+        if len(data_bytes) != SET_CARD_DATA_BYTES:
+            return {"error": f"数据长度异常: {len(data_bytes)} != {SET_CARD_DATA_BYTES}"}
+
+        # 构建非标准 0x10 请求帧(无 byte count 字段)
+        header = bytes([device_address, 0x10]) + struct.pack('>HH', SET_CARD_REG_ADDR, SET_CARD_QUANTITY)
+        request = header + data_bytes + calculate_crc16(header + data_bytes)
+
+        if len(request) != SET_CARD_REQUEST_LEN:
+            return {"error": f"请求帧长度异常: {len(request)} != {SET_CARD_REQUEST_LEN}"}
+
+        logger.info(f"设置卡号帧({len(request)}B): {request.hex()}")
+
+        response = self.serial.send_and_wait(
+            request, timeout=timeout, min_response_bytes=SET_CARD_RESPONSE_LEN
+        )
+
+        if not response or len(response) < 5:
+            return {"error": "response timeout or too short",
+                    "raw_data": response.hex() if response else ""}
+
+        # Modbus 异常响应: addr + 0x90 + exception_code + CRC
+        if response[1] & 0x80:
+            return {"error": f"Modbus exception: {response[2]:#x}",
+                    "raw_data": response.hex()}
+
+        if len(response) < SET_CARD_RESPONSE_LEN:
+            return {"error": f"response too short ({len(response)}/{SET_CARD_RESPONSE_LEN})",
+                    "raw_data": response.hex()}
+
+        if not verify_crc16(response):
+            logger.warning(f"设置卡号响应 CRC 失败: {response.hex()}")
+            return {"error": "CRC check failed", "raw_data": response.hex()}
+
+        if response[1] != 0x10:
+            return {"error": f"非预期功能码: {response[1]:#x}", "raw_data": response.hex()}
+
+        resp_start = struct.unpack('>H', response[2:4])[0]
+        resp_qty = struct.unpack('>H', response[4:6])[0]
+        if resp_start != SET_CARD_REG_ADDR or resp_qty != SET_CARD_QUANTITY:
+            return {"error": f"响应地址/数量不匹配: start={resp_start:#x} qty={resp_qty:#x}",
+                    "raw_data": response.hex()}
+
+        return {
+            "success": True,
+            "device_address": response[0],
+            "function_code": 0x10,
+            "cards_written": written,
+            "raw_data": response.hex()
+        }
+
     def read_holding_registers(self, device_address: int, start_address: int, quantity: int = 1, timeout: float = None) -> dict:
         """读取保持寄存器"""
         request_data = struct.pack('>HH', start_address, quantity)

+ 9 - 0
backend/mqtt_config.json

@@ -0,0 +1,9 @@
+{
+  "broker": "localhost",
+  "port": 1883,
+  "client_id": "test_client",
+  "username": "",
+  "password": "",
+  "keepalive": 60,
+  "topics": []
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/About-16c37bd6.js


+ 0 - 1
backend/static/assets/About-2016965a.css

@@ -1 +0,0 @@
-.about-page[data-v-067009f4]{width:100%}.about-content[data-v-067009f4]{display:flex;flex-direction:column;gap:32px}.software-info[data-v-067009f4]{display:flex;flex-direction:column;align-items:center;text-align:center;padding:24px;background:linear-gradient(135deg,#f5f7fa 0%,#c3cfe2 100%);border-radius:8px}.logo-section[data-v-067009f4]{display:flex;flex-direction:column;align-items:center;gap:12px;margin-bottom:24px}.software-name[data-v-067009f4]{margin:0;font-size:28px;font-weight:700;color:#1890ff}.software-version[data-v-067009f4]{margin:0;font-size:16px;color:#666}.description-section[data-v-067009f4]{max-width:800px}.description-section h3[data-v-067009f4]{margin-bottom:16px;font-size:20px;font-weight:600}.description[data-v-067009f4]{line-height:1.8;font-size:16px;color:#333}.features-section h3[data-v-067009f4]{margin-bottom:24px;font-size:20px;font-weight:600;text-align:center}.tech-stack-section h3[data-v-067009f4]{margin-bottom:16px;font-size:20px;font-weight:600}.tech-stack[data-v-067009f4]{display:flex;flex-wrap:wrap;gap:8px}.license-section[data-v-067009f4],.contact-section[data-v-067009f4]{border-top:1px solid #f0f0f0;padding-top:24px}.license-section h3[data-v-067009f4],.contact-section h3[data-v-067009f4]{margin-bottom:16px;font-size:20px;font-weight:600}.license-info[data-v-067009f4]{line-height:1.6;color:#666}@media (max-width: 768px){.about-content[data-v-067009f4]{gap:24px}.software-name[data-v-067009f4]{font-size:24px}.description[data-v-067009f4]{font-size:14px}}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/About-98922cff.js


+ 1 - 0
backend/static/assets/About-a3a5268b.css

@@ -0,0 +1 @@
+.about-page[data-v-f8f1a118]{width:100%}.about-content[data-v-f8f1a118]{display:flex;flex-direction:column;gap:32px}.software-info[data-v-f8f1a118]{display:flex;flex-direction:column;align-items:center;text-align:center;padding:24px;background:linear-gradient(135deg,#f5f7fa 0%,#c3cfe2 100%);border-radius:8px}.logo-section[data-v-f8f1a118]{display:flex;flex-direction:column;align-items:center;gap:12px;margin-bottom:24px}.software-name[data-v-f8f1a118]{margin:0;font-size:28px;font-weight:700;color:#1890ff}.software-version[data-v-f8f1a118]{margin:0;font-size:16px;color:#666}.description-section[data-v-f8f1a118]{max-width:800px}.description-section h3[data-v-f8f1a118]{margin-bottom:16px;font-size:20px;font-weight:600}.description[data-v-f8f1a118]{line-height:1.8;font-size:16px;color:#333}.features-section h3[data-v-f8f1a118]{margin-bottom:24px;font-size:20px;font-weight:600;text-align:center}.tech-stack-section h3[data-v-f8f1a118]{margin-bottom:16px;font-size:20px;font-weight:600}.tech-stack[data-v-f8f1a118]{display:flex;flex-wrap:wrap;gap:8px}.license-section[data-v-f8f1a118],.contact-section[data-v-f8f1a118],.system-operations[data-v-f8f1a118]{border-top:1px solid #f0f0f0;padding-top:24px}.system-operations h3[data-v-f8f1a118],.license-section h3[data-v-f8f1a118],.contact-section h3[data-v-f8f1a118]{margin-bottom:16px;font-size:20px;font-weight:600}.license-info[data-v-f8f1a118]{line-height:1.6;color:#666}@media (max-width: 768px){.about-content[data-v-f8f1a118]{gap:24px}.software-name[data-v-f8f1a118]{font-size:24px}.description[data-v-f8f1a118]{font-size:14px}}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/AlarmStatus-a87db74d.js


+ 1 - 0
backend/static/assets/AlarmStatus-d23d9680.css

@@ -0,0 +1 @@
+.alarm-status[data-v-192a0b7b]{display:flex;flex-direction:column;gap:16px}.page-header[data-v-192a0b7b]{margin-bottom:8px}.page-title[data-v-192a0b7b]{margin:0;font-size:20px;font-weight:600;color:#333}.overview-card[data-v-192a0b7b]{flex-shrink:0}.overview-bar[data-v-192a0b7b]{display:flex;align-items:center;gap:24px;flex-wrap:wrap}.overview-item[data-v-192a0b7b]{display:flex;align-items:center;gap:8px}.overview-label[data-v-192a0b7b]{font-size:14px;color:#666}.overview-value[data-v-192a0b7b]{font-size:14px;color:#333}.overview-actions[data-v-192a0b7b]{margin-left:auto;display:flex;gap:12px}.events-card[data-v-192a0b7b]{flex:1;min-height:400px}.timestamp[data-v-192a0b7b]{font-family:Monaco,Menlo,monospace;font-size:13px}.text-muted[data-v-192a0b7b]{color:#999}.text-danger[data-v-192a0b7b]{color:#ff4d4f;font-weight:500}@media (max-width: 768px){.overview-bar[data-v-192a0b7b]{flex-direction:column;align-items:flex-start}.overview-actions[data-v-192a0b7b]{margin-left:0;margin-top:12px;width:100%}.overview-actions button[data-v-192a0b7b]{flex:1}}

+ 1 - 0
backend/static/assets/EnvSensor-7e1ba4be.css

@@ -0,0 +1 @@
+.env-sensor[data-v-336df9ac]{padding:24px}.page-header[data-v-336df9ac]{margin-bottom:24px}.page-title[data-v-336df9ac]{font-size:24px;font-weight:600;margin:0}.sensor-card[data-v-336df9ac]{text-align:center}.sensor-card[data-v-336df9ac] .ant-card-body{padding:24px}.sensor-content[data-v-336df9ac]{display:flex;align-items:center;justify-content:center;gap:16px}.sensor-icon[data-v-336df9ac]{font-size:48px;padding:16px;border-radius:12px}.temp-card .sensor-icon[data-v-336df9ac]{color:#ff6b6b;background:rgba(255,107,107,.1)}.humidity-card .sensor-icon[data-v-336df9ac]{color:#74b9ff;background:rgba(116,185,255,.1)}.dtu-temp-card .sensor-icon[data-v-336df9ac]{color:#ffa502;background:rgba(255,165,2,.1)}.sensor-info[data-v-336df9ac]{text-align:left}.sensor-label[data-v-336df9ac]{font-size:14px;color:#00000073;margin-bottom:4px}.sensor-value[data-v-336df9ac]{font-size:28px;font-weight:600;color:#000000d9}.detail-card[data-v-336df9ac],.threshold-card[data-v-336df9ac],.chart-card[data-v-336df9ac]{margin-top:24px}.card-header[data-v-336df9ac]{display:flex;align-items:center;gap:8px}.text-success[data-v-336df9ac]{color:#52c41a}.text-warning[data-v-336df9ac]{color:#faad14}.text-danger[data-v-336df9ac]{color:#ff4d4f}.chart-container[data-v-336df9ac]{min-height:300px}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/EnvSensor-ce44d362.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/JumperStatus-27af676c.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/JumperStatus-4cbd022b.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/LedDebug-ab46e20b.js


+ 1 - 0
backend/static/assets/LedDebug-d6c41b17.css

@@ -0,0 +1 @@
+.card-debug[data-v-7d60b45a]{width:100%}.port-grid[data-v-7d60b45a]{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}@media (max-width: 768px){.port-grid[data-v-7d60b45a]{grid-template-columns:repeat(3,1fr)}}@media (max-width: 480px){.port-grid[data-v-7d60b45a]{grid-template-columns:repeat(2,1fr)}}.port-cell[data-v-7d60b45a]{display:flex;align-items:center;gap:10px;cursor:pointer;padding:8px;border-radius:10px;border:2px solid transparent;transition:all .2s}.port-cell.active[data-v-7d60b45a]{border-color:#1890ff;background:#e6f7ff}.port-dot[data-v-7d60b45a]{width:48px;height:48px;border-radius:10px;display:flex;align-items:center;justify-content:center;border:2px solid rgba(0,0,0,.1);flex-shrink:0}.port-dot.present[data-v-7d60b45a]{background:#52c41a}.port-dot.empty[data-v-7d60b45a]{background:#d9d9d9}.port-dot.unknown[data-v-7d60b45a]{background:#bfbfbf}.port-label[data-v-7d60b45a]{font-size:16px;font-weight:700;color:#fff;text-shadow:0 1px 3px rgba(0,0,0,.4)}.port-info[data-v-7d60b45a]{flex:1;min-width:0}.port-status-text[data-v-7d60b45a]{font-size:13px;font-weight:600;color:#333}.port-uid-text[data-v-7d60b45a]{font-size:11px;font-family:Monaco,Menlo,monospace;color:#666;word-break:break-all}.legend[data-v-7d60b45a]{display:flex;gap:20px;margin-top:16px;flex-wrap:wrap;font-size:13px;color:#666}.legend-item[data-v-7d60b45a]{display:flex;align-items:center;gap:6px}.legend-dot[data-v-7d60b45a]{width:14px;height:14px;border-radius:4px;border:1px solid rgba(0,0,0,.1)}.legend-dot.present[data-v-7d60b45a]{background:#52c41a}.legend-dot.empty[data-v-7d60b45a]{background:#d9d9d9}.legend-dot.unknown[data-v-7d60b45a]{background:#bfbfbf}.card-uid[data-v-7d60b45a]{font-family:Monaco,Menlo,monospace;font-size:13px;color:#333;word-break:break-all}

+ 1 - 0
backend/static/assets/NetworkConfig-47a0fff5.css

@@ -0,0 +1 @@
+.network-config[data-v-7e52d606]{width:100%;padding:20px}.ant-form-item[data-v-7e52d606]{margin-bottom:20px}.ant-descriptions-item-label[data-v-7e52d606]{font-weight:600}.ant-alert[data-v-7e52d606]{margin-top:20px}@media (max-width: 768px){.network-config[data-v-7e52d606]{padding:10px}.ant-form-horizontal .ant-form-item-label[data-v-7e52d606]{text-align:left}}

+ 0 - 1
backend/static/assets/NetworkConfig-6f5efcd1.css

@@ -1 +0,0 @@
-.network-config[data-v-f58c978e]{width:100%;padding:20px}.ant-form-item[data-v-f58c978e]{margin-bottom:20px}.ant-descriptions-item-label[data-v-f58c978e]{font-weight:600}.ant-alert[data-v-f58c978e]{margin-top:20px}@media (max-width: 768px){.network-config[data-v-f58c978e]{padding:10px}.ant-form-horizontal .ant-form-item-label[data-v-f58c978e]{text-align:left}}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/NetworkConfig-a54a5ccb.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/NetworkConfig-aca027d0.js


+ 1 - 0
backend/static/assets/OTAUpgrade-0dadd681.css

@@ -0,0 +1 @@
+.ota-upgrade[data-v-9342a8de]{display:flex;flex-direction:column;gap:16px}.page-header[data-v-9342a8de]{margin-bottom:8px}.page-title[data-v-9342a8de]{margin:0;font-size:20px;font-weight:600;color:#333}.status-card[data-v-9342a8de]{flex-shrink:0}.status-header[data-v-9342a8de]{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.status-header h3[data-v-9342a8de]{margin:0;font-size:16px;font-weight:500}.error-text[data-v-9342a8de]{color:#ff4d4f}.text-muted[data-v-9342a8de]{color:#999}.config-card h3[data-v-9342a8de]{margin:0 0 16px;font-size:16px;font-weight:500}.switch-hint[data-v-9342a8de]{margin-left:8px;color:#666;font-size:12px}.help-card h3[data-v-9342a8de]{margin:0 0 16px;font-size:16px;font-weight:500}.help-card h4[data-v-9342a8de]{margin:12px 0 8px;font-size:14px;font-weight:500}.help-card ul[data-v-9342a8de]{margin:0;padding-left:20px}.help-card li[data-v-9342a8de]{margin-bottom:8px;color:#666}@media (max-width: 768px){.config-card[data-v-9342a8de] .ant-row>.ant-col{margin-bottom:0}}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/OTAUpgrade-676eff63.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/PanelStatus-345febed.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/PanelStatus-66a71c0a.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/PortStatus-bfb07be0.css


Разница между файлами не показана из-за своего большого размера
+ 2 - 0
backend/static/assets/PortStatus-e8439e28.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/RealTimeStatus-2b7e18f7.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/RealTimeStatus-53aebb00.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/RealTimeStatus-7cb17239.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/RealTimeStatus-d00a8416.css


Разница между файлами не показана из-за своего большого размера
+ 13 - 0
backend/static/assets/index-12f90946.js


Разница между файлами не показана из-за своего большого размера
+ 4 - 0
backend/static/assets/index-29b7bbe1.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 4
backend/static/assets/index-af4a73a6.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/index-d2e45fca.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
backend/static/assets/index-ef806d81.css


+ 2 - 2
backend/static/index.html

@@ -4,8 +4,8 @@
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>串口-MQTT 转发网关</title>
-  <script type="module" crossorigin src="/assets/index-af4a73a6.js"></script>
-  <link rel="stylesheet" href="/assets/index-d2e45fca.css">
+  <script type="module" crossorigin src="/assets/index-29b7bbe1.js"></script>
+  <link rel="stylesheet" href="/assets/index-ef806d81.css">
 </head>
 <body>
     <div id="app"></div>

+ 699 - 0
backend/static/vite.svg

@@ -0,0 +1,699 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="2405px" height="1360px" viewBox="0 0 2405 1360" enable-background="new 0 0 2405 1360" xml:space="preserve">  <image id="image0" width="2405" height="1360" x="0" y="0"
+    href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAACWUAAAVQCAMAAAAeYFrBAAAABGdBTUEAALGPC/xhBQAAACBjSFJN
+AAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAASFBMVEVHcEz6+/3W8v6zz+aQ
+yvFht+MYkO8YmfMUnvYVpPVpwvxkpMYxh7Hs6OybsMYyY9ggeecchuzV0dQgfecncOJ/hrVcXMH/
+//8A+sXiAAAAAXRSTlMAQObYZgAAAAFiS0dEFwvWmI8AAAAHdElNRQfnCRQGJw9bCfBlAAACs3pU
+WHRSYXcgcHJvZmlsZSB0eXBlIHhtcAAAOI2NVV3SmzAMfPcpegRbkiU4Dgn4rTN97PG7K5IQEr6Z
+wgwYS9ZKqx/K399/yi9e02xF7zpiiurN1W/ew6S6ePfw2TddRbZxu92GCPZnN+700G6rVlujmkJ3
+8rnYFEvgYNdYbOvmeMOgKg6J6NBNqt5j0iUmx0FfCeZNKr/97lsoZYUI8MZ80A9ddsFLPT05zGDv
+xhP2OiG1T7b2WoTOjcgt7bKpygp/qjYFqiJe7DU17Vxj745dSbnIwHt/epE11ZZUXKASUj9ueYQn
+8AI6XczMP0KTkkKGN4XhrrognBF5yRbQki09DiA3nXm/PBE81x0AHoUG8kNGYkJYQKD87AVcQKqQ
+CPE5mZrBEDSecm8FhI0AsfRqJ/Y9FyTYVqT6y+cE3I40FXyAbF8RzgSfKgMAvXjD4PrM2ptxDeus
+sw9CypX1n42zEgPBQWfQXGjGI64FpZj2ycsZG2b1Ig01D96PFFBPBgzhYGcBknB4tqO3i6CSsZ/Y
+Kv8T0NPkg3jmcCCDL3ibzbJFdiUYsmzLxVRRc+rWlauWJVvz3azjbgoXIdeUUK8VPLiYraFaP5FZ
+X8oKc3rbiXwCng/gciCjZv8D+RoY6TdWawcnxix0Rj/8/tXhFTNnpI7mqrLZgY+e6zm52GvsrKMp
+zqT2bAoHFD6wZjLqW1Eu/gi/fMXfkY2M3B0y1+7sJ8CjDzG5GGlODPrbsNcYOcjGkgsSgbKFwqSC
+4833znvHvkg6oXECMOAICwRrQpKNIw1FrixX59wxQRK6Kci1pHqGpGONOSWPQUzlAqQT3R9DDEFH
+O++h0mfdMhUrGQKH5Ajip4m7jLMyjx9770b3Dnt3ohxeHEeux9iX4Ry5z93y/aPZRRd/O1C8/9dC
+9h9V+QenRaN07Lz36gAAgABJREFUeNrt3YmCqji3hmEjFonNjOj9X2qLQ+2qUpA5w3qf7j7n795l
+qSGEj0UIux0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8
+pD6w/fkAwDUMm05iG8ANty6430eHw+HrK9ZaG6Ovf90dH/9cXf9Ex/F//11/LtrTZwGI9W/QPPx3
+Hzb1bej8Q9+Gza+v26gZ7fcc77eirtvmK0nSVnJzyGx/JgjyTPdZdv07iw75IS+Ksqq0Pn50TVpV
+VRbR9aWcrQGQQ/0bOevrqHk9K72OmteEZfrHzPYMNa5OzdfXVzvWRhmj5rquG6jOk6oNvY8NcDQ6
+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
+qpPm9Khs3YbJpmaQBPCXiqLoEB3auVwvDl+Hp2hv+4MCgEOuY+d1aMy/kq8kydtRkpAFAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABCj/2oSy/T0BAAC2VRyNNlda
+t3+vov21te3vCThO5Zdv51//Px93kqIuv5y//1du+yt6LDtf1nFmbASCVh03cbb9PQHX/dgX9e/d
+Jx71e8rfL/73uxrb39Bj6w2UJ9tfDcCaNkpZF9vfE3Bd2bn7mFG/59T1a0rb39Bj8WpjY2X7qwFY
+E7UswA119/4z5pJhtchvwW+kLACTUMsC3KC6958xM6q6d2nbX9BnpCwAk5CyAEeYzv1nzNwdDudr
+IGUBmISUBTii+0heDb/Y110RGzeHHr+QsgBMwrwswBF596F8eELqnEM/IqnhBSkLwCTUsgBXrJqy
+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
+2zxRy5ryajXOp7fqS1n7ke/18c2mIGX1fkpS1uDeISlljR0mPnZKUtbk/kDKGtu6f1DLmvBtnqhl
+TXj1aXDzDjMrD2yBlNX7KUlZg3uHpJS1+IV7UtYvY/oDKWtm36SWNeHbPFHLmvDqaHDzDkPKGtLj
+WqSsf0hZfb2QlDUZKWv6+ziFWtZ71LJmvc/QdyVljUPK6v2UpKzBvYOUNQMp65cx/YGUNbNvUsua
+8G2eqGVNeDUpa1RL/kbKWqi1SFl9vZCUNRkpa/r7OIVa1nvUsma9z9B3nZmy9oObdxhS1pAe1yJl
+/UPK6uuFpKzJSFnT38cp1LLeo5Y1632Gviu1rHFIWb2fkpQ1uHeQsmYgZf0ypj+Qsmb2TWpZE77N
+E7WsCa8mZY1qyd9IWQu1FimrrxeSsiYjZU1/H6dQy3qPWtas9xn6rqSscUhZvZ+SlDW4d5CyZiBl
+/TKmP5CyZvZNalkTvs0TtawJr2Ze1qiW/I2UtVBrkbL6eiEpazJS1vT3cco2KcuQsj6hljXh1dSy
+RrXkb2ulrJc8Q8oajpQ1tc1JWX84kbJeDoCkrJl9sydl/dmfvE5ZL/sRtawPL+35aqSsUXxIWdSy
+ZljraPfylARS1gykrF96NtPLi0lZM/tmzxXDP4Opzynrb12OWhYpazOkrN5PScrq+ogv19ZJWTOQ
+sn4Z0+9IWTP7poyU9To2UMv68NKer0bKGoWU1fspSVldH5GUtSRS1i89m+ll/yZlzeybPSnrz096
+nbJeOo6ztazXI0v3z5KydqSsYT2uRcr6x4eU9dLOpKwZSFm/dL9Wk7JaS6asnt/15ydJWS8m17LG
+zCAjZfUjZQ3pca0xvY6UNdxmRztS1gykrF96ugMpq0XKem94Xc7hWhYpayRS1pAe1yJl/UPK6uuF
+pKzJSFlD30dQyuppwD8/6X7KGp4YbdeyevaP+uWHu3+WlLUjZQ3rca2e/ePl2EnKGm6zox0pawZS
+1i/dr309/pGy5vVNRcqa8G2e1qhlvY5i3T9LytqRsob1uFbP/vHSUKSs4dZKWS8/S8qagZT1U8/+
+Tcq6WTJl9RwC/vyo+ylreGK0XcsaM4OMlNWPlDWkx3148bKtSsp6WvZoR8qagZT1U0+/K19+mJQ1
+r2/m3YeAv/uE8ylrRF3Odi1r+PoZO1LWB6SsIT1uN3L/IGUNN+dol4359qSsGUhZP43qd6SseX2z
+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
+UhYpa5bOTrddyirXa39PU1Y15ft8aouel4aYspiXteDXImUtr2Mn9zNldeUQ92pZnTe0Tfh2T6Ss
+WcJPWd0PBl2rTV/eiZT1Vznl+3xqi56XhpiyfKlluZSyOg8JpKzFdawn4GfKqrva0LlaVvdTHrpf
+Q8rakbJmNWbnG3HFsNeaKes86ft8aouel26VsjZZi+CJeVmzW2z7fueSdVNW13f3MmV1tqFztazu
+wNT9GlLWjpQ1R/fhgpTVa82UNe37fGqLnpcGmbI8qWU5NC/LgX7nklVTVueZlIcpq+pepNexWpYu
+u9+l51WkrFVT1jJP+XA3ZXWfvXLFsN96Kau6TPs+n9qi56VBpqyXoZ95Wb2qnmMJKWtpne/qY8rq
+bkPHalm65116XkXKImVNZ3reiJTVa72UFU/8Pp/aYtRbBpCy1rxiGGIty4l+55IVU1bZfdyekbKq
+oueywHopq29ncKuW1ReySFm/kLI2+XYzWrWqip6v/PJOPUPD3Pb3L2WZYur3+dQWPS8NcfY79xiO
+ot3ody5ZM2V1v+uclKXG7HuLjY19PcetWlZvf+t+GSlrR8qarLfzzGjVWMWkrIn692hWJR2MewxH
+iXv7HSlrSae+XWh6yqp6t9NLHWehsbF/wHKplmXq3neZ+hV3pKyZQp79/mH7T2/V6wYaNSoz+/3b
+p8eTk7KG86WW9Xs/tJOy4g/9jpS1pN6Rd3rKKqykrP5t6FIt60Nv62k6UhYpa5pPXWd6q5YjR2VS
+1rdP3YGUNZg3tSwnUtaM/WjRfueSdVJW9SHQTkxZ95kGm18xLD8cRdypZcX1h3fpfikpa0fKGs/E
+xadj5dRWNeWnT/nyTqSse8tVl097s+8pK5jZ7yHNy9ID+h0payl9awnc5NMKN/ej4tYp62P+cKeW
+9fFQMuNbkrJmCTRlxdHHcXVqq37c20lZ75lq//n7kLKG86WWZTllDep3IlNWfV5YXn88t93tsnK0
+5vKs01ya6789/+vvn3k53FfaHM0M+nT5/HV6vs3l44uf9tWMj3k0Oj5dPhWyrro/afHpaHnufu35
+w0ubP9vpw5b+sxXnbL+3bfWt/PtB1cLv9f2e2iwz+72o4neu/7Va5g3WoCbs7cWzfnAuvv/byw+9
+vNPl5Ue+/0c+7jO/yPL8svRY+RgwrWyUuuhu/E+vLTr35+J1wLu8bItvn4bWovMzvo70u4teaec1
+5uVzNsv97r7dNuveRm/97klR3PWeum/dlZWdu7/TxxGs+yjSfDoEiaT2Y6khL35JCvuvWf47fD5T
+7/tAg179+B1zP+mA84hRTbfgtxy5tf/8vnkN0+f1yLvee0WfGmmQ/aHTiL62ta329mX2RCFmDAXj
+Xjpjm4x5o2i9ffflzfI1x6BB339Am/Qd+zbvbVM7z1IvHeN/+QZE8kaqKaYAAAAldEVYdGRhdGU6
+Y3JlYXRlADIwMjMtMDktMjBUMDY6Mzk6MTQrMDA6MDDCbHAzAAAAJXRFWHRkYXRlOm1vZGlmeQAy
+MDIzLTA5LTIwVDA2OjM5OjE0KzAwOjAwszHIjwAAACh0RVh0ZGF0ZTp0aW1lc3RhbXAAMjAyMy0w
+OS0yMFQwNjozOToxNCswMDowMOQk6VAAAAAZdEVYdFNvZnR3YXJlAEFkb2JlIEltYWdlUmVhZHlx
+yWU8AAAAAElFTkSuQmCC" />
+</svg>

+ 32 - 0
frontend/package-lock.json

@@ -12,6 +12,7 @@
         "ant-design-vue": "^4.2.6",
         "axios": "^1.6.2",
         "core-js": "^3.8.3",
+        "echarts": "^6.1.0",
         "pinia": "^2.1.6",
         "socket.io-client": "^4.7.2",
         "vue": "^3.2.13",
@@ -1919,6 +1920,22 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/echarts": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz",
+      "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "6.1.0"
+      }
+    },
+    "node_modules/echarts/node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
+    },
     "node_modules/emoji-regex": {
       "version": "8.0.0",
       "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -3989,6 +4006,21 @@
       "engines": {
         "node": ">=0.4.0"
       }
+    },
+    "node_modules/zrender": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz",
+      "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
+    },
+    "node_modules/zrender/node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
     }
   }
 }

+ 2 - 2
frontend/src/App.vue

@@ -53,8 +53,8 @@
             <a-menu-item key="/system-config" icon="<setting />">
               <router-link to="/system-config">系统配置</router-link>
             </a-menu-item>
-            <a-menu-item key="/led-debug" icon="<bulb />">
-              <router-link to="/led-debug">LED调试</router-link>
+            <a-menu-item key="/led-debug" icon="<idcard />">
+              <router-link to="/led-debug">卡号设置测试</router-link>
             </a-menu-item>
             <a-menu-item key="/port-status" icon="<api />">
               <router-link to="/port-status">端口状态</router-link>

+ 1 - 0
frontend/src/api/apiService.js

@@ -67,6 +67,7 @@ const apiService = {
     getAntennaAddresses: () => apiClient.get('/modbus/antenna_addresses'),
     readAntenna: (params) => apiClient.post('/modbus/read_antenna', params),
     readAllCards: (params) => apiClient.post('/modbus/read_all_cards', params),
+    setCardMapping: (params) => apiClient.post('/modbus/set_card_mapping', params),
     readRegisters: (params) => apiClient.post('/modbus/read_registers', params),
     writeRegister: (params) => apiClient.post('/modbus/write_register', params),
     setRgbLed: (params) => apiClient.post('/modbus/set_rgb_led', params),

+ 1 - 1
frontend/src/router/index.js

@@ -28,7 +28,7 @@ const routes = [
     path: '/led-debug',
     name: 'LedDebug',
     component: () => import('../views/LedDebug.vue'),
-    meta: { title: 'LED调试' }
+    meta: { title: '卡号设置测试' }
   },
   {
     path: '/port-status',

+ 57 - 13
frontend/src/views/AlarmStatus.vue

@@ -55,11 +55,11 @@
             <span v-else class="text-muted">-</span>
           </template>
           <template v-else-if="column.key === 'expected_jumper'">
-            <span>{{ record.expected_jumper_uid || '-' }}</span>
+            <span>{{ formatUid(record.expected_jumper_uid) || '-' }}</span>
           </template>
           <template v-else-if="column.key === 'actual_jumper'">
-            <span :class="{ 'text-danger': record.actual_jumper_uid !== record.expected_jumper_uid }">
-              {{ record.actual_jumper_uid || '未读到' }}
+            <span :class="{ 'text-danger': normalizeUid(record.actual_jumper_uid) !== normalizeUid(record.expected_jumper_uid) }">
+              {{ formatUid(record.actual_jumper_uid) || '未读到' }}
             </span>
           </template>
           <template v-else-if="column.key === 'timestamp'">
@@ -160,16 +160,56 @@ function formatTime(timestamp) {
   }
 }
 
+// 生成事件去重 key(同一条告警/事件无论从轮询还是实时推送来,key 必须一致)
+function eventDedupeKey(e) {
+  // 后端事件: 用 panel_id + port_id + timestamp + event_type + alarm_type
+  // timestamp 是后端毫秒级时间戳,同端口同时刻同类型视为同一条
+  // expected/actual 统一用 12 位规范化值参与 key,避免 16位MQTT格式 与 12位内部格式 视为不同
+  const exp = normalizeUid(e.expected_jumper_uid) || ''
+  const act = normalizeUid(e.actual_jumper_uid) || ''
+  return `${e.panel_id || ''}|${e.port_id || ''}|${e.timestamp || ''}|${e.event_type || ''}|${e.alarm_type || ''}|${exp}|${act}`
+}
+
+// 统一规范化 jumper_uid:
+// - MQTT 推送的是 16 位(前4位 DTU 号 + 后12位卡号)→ 剥前4位得 12 位
+// - 后端 HTTP 接口返回的是 12 位内部格式 → 原样
+// - 非法长度/格式 → 返回空串
+function normalizeUid(uid) {
+  if (!uid) return ''
+  const s = String(uid).toUpperCase().replace(/[:\s-]/g, '')
+  if (s.length === 16 && /^[0-9A-F]{16}$/.test(s)) return s.slice(4)   // 16→12
+  if (s.length === 12 && /^[0-9A-F]{12}$/.test(s)) return s            // 12→12
+  return ''
+}
+
+// 展示用:返回规范化后的 12 位 hex,或空串
+function formatUid(uid) {
+  return normalizeUid(uid)
+}
+
 // 加载事件数据
 async function loadEvents() {
   try {
     const res = await apiService.port.getEvents(100)
     if (res.data.success) {
-      // 添加唯一key
-      events.value = (res.data.events || []).map((e, i) => ({
+      const fetched = (res.data.events || []).map((e, i) => ({
         ...e,
-        key: `${e.timestamp}-${i}`
+        key: `${e.timestamp}-${i}`,
+        _dedupe: eventDedupeKey(e)
       }))
+      // 与实时推送过的记录合并去重:保留已存在于 events 中的实时记录(可能在 fetched 窗口之外),
+      // 同时按 _dedupe 去重,避免同一条告警被计两次
+      const existingRealtime = events.value.filter(
+        rt => !fetched.some(f => f._dedupe === rt._dedupe)
+      )
+      // 合并 + 按时间倒序排序
+      const merged = [...fetched, ...existingRealtime]
+      merged.sort((a, b) => {
+        const ta = new Date(a.timestamp).getTime() || 0
+        const tb = new Date(b.timestamp).getTime() || 0
+        return tb - ta
+      })
+      events.value = merged.slice(0, 500)
     }
   } catch (e) {
     console.error('load events error:', e)
@@ -229,21 +269,25 @@ watch(
       try {
         const parsed = typeof item.payload === 'string' ? JSON.parse(item.payload) : item.payload
         const p = parsed?.payload || parsed || {}
+        // 实时推送的 timestamp 后端是毫秒数,统一转 ISO 字符串后参与去重 key
+        const tsIso = parsed?.timestamp
+          ? new Date(parsed.timestamp).toISOString()
+          : (item.timestamp || new Date().toISOString())
         const rec = {
           key: `rt-${parsed?.msg_id || Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
-          timestamp: parsed?.timestamp
-            ? new Date(parsed.timestamp).toISOString()
-            : item.timestamp || new Date().toISOString(),
+          timestamp: tsIso,
           panel_id: p.panel_id,
           port_id: p.port_id,
           event_type: isAlarm ? 'ALARM' : 'EVENT',
           alarm_type: p.alarm_type || (isEvent ? p.event_type : null),
-          expected_jumper_uid: p.expected_jumper_uid || null,
-          actual_jumper_uid: p.actual_jumper_uid ?? p.jumper_uid ?? null,
+          // MQTT 推送的是 16 位(带 DTU 前缀),统一规范化为 12 位内部格式存储,与 HTTP 接口一致
+          expected_jumper_uid: normalizeUid(p.expected_jumper_uid) || null,
+          actual_jumper_uid: normalizeUid(p.actual_jumper_uid ?? p.jumper_uid) || null,
           severity: p.severity || null
         }
-        // 去重(同 msg_id 只插一次):按 key 前缀检测
-        if (!events.value.find(e => e.key === rec.key)) {
+        rec._dedupe = eventDedupeKey(rec)
+        // 去重:若 events 中已存在相同 _dedupe 的记录(轮询拉回的或之前推送过的),不再插入
+        if (!events.value.find(e => e._dedupe === rec._dedupe)) {
           events.value = [rec, ...events.value].slice(0, 500)
           lastUpdate.value = new Date().toLocaleTimeString()
         }

+ 4 - 1
frontend/src/views/EnvSensor.vue

@@ -401,11 +401,14 @@ const renderChart = async () => {
       }
     },
     legend: {
-      data: ['环境温度', '环境湿度', 'DTU温度']
+      data: ['环境温度', '环境湿度', 'DTU温度'],
+      top: 0,
+      left: 'center'
     },
     grid: {
       left: '3%',
       right: '4%',
+      top: 40,
       bottom: '3%',
       containLabel: true
     },

+ 167 - 161
frontend/src/views/LedDebug.vue

@@ -1,8 +1,8 @@
 <template>
-  <div class="led-debug">
+  <div class="card-debug">
     <a-row :gutter="[24, 24]">
       <a-col :xs="24" :lg="10">
-        <a-card title="LED 控制面板">
+        <a-card title="设置卡号(V4 协议 0x0003)">
           <a-form layout="vertical">
             <a-form-item label="选择终端">
               <a-select
@@ -19,48 +19,42 @@
                 </a-select-option>
               </a-select>
             </a-form-item>
-            <a-form-item label="选择 LED">
+            <a-form-item label="选择端口">
               <a-select
-                v-model:value="selectedLed"
+                v-model:value="selectedPort"
                 style="width: 100%"
               >
-                <a-select-option :value="0">全部 24 灯</a-select-option>
-                <a-select-option v-for="n in 24" :key="n" :value="n">LED {{ n }}</a-select-option>
+                <a-select-option v-for="n in 24" :key="n" :value="n">端口 P{{ n }}</a-select-option>
               </a-select>
             </a-form-item>
+            <a-form-item label="卡号(12 位十六进制,硬件 6 字节)">
+              <a-input
+                v-model:value="cardUidInput"
+                placeholder="如 ED9A57F95001"
+                :maxlength="12"
+                style="font-family: 'Monaco', 'Menlo', monospace; text-transform: uppercase; letter-spacing: 1px"
+                allow-clear
+              />
+              <div style="font-size: 12px; color: #999; margin-top: 4px">
+                无分隔符,自动转大写;留空点击「清除」可移除该端口卡号
+              </div>
+            </a-form-item>
             <a-form-item label="操作">
               <a-space wrap>
                 <a-button
-                  danger
                   type="primary"
-                  :disabled="!canControl"
-                  :loading="loading === 1"
-                  @click="setColor(1)"
-                >
-                  🔴 红灯闪烁
-                </a-button>
-                <a-button
-                  :disabled="!canControl"
-                  :loading="loading === 2"
-                  style="background:#52c41a;border-color:#52c41a;color:#fff"
-                  @click="setColor(2)"
+                  :disabled="!canSet"
+                  :loading="setting"
+                  @click="setCard"
                 >
-                  🟢 绿灯闪烁
+                  设置卡号
                 </a-button>
                 <a-button
-                  type="primary"
-                  :disabled="!canControl"
-                  :loading="loading === 3"
-                  @click="setColor(3)"
+                  :disabled="!canControl || setting"
+                  :loading="setting"
+                  @click="clearCard"
                 >
-                  🔵 蓝灯闪烁
-                </a-button>
-                <a-button
-                  :disabled="!canControl"
-                  :loading="loading === 0"
-                  @click="setColor(0)"
-                >
-                  ⚫ 关闭
+                  清除该端口卡号
                 </a-button>
               </a-space>
             </a-form-item>
@@ -77,7 +71,7 @@
           />
         </a-card>
 
-        <a-card title="读卡号(V4 批量 24 路)" style="margin-top: 16px">
+        <a-card title="读卡号验证(V4 批量 24 路)" style="margin-top: 16px">
           <a-button type="primary" @click="readAllCards" :loading="readingCard" :disabled="!canControl">
             读取全部卡号
           </a-button>
@@ -105,12 +99,12 @@
               </template>
             </template>
           </a-table>
-          <a-empty v-else-if="!readingCard" description="点击「读取全部卡号」查看 24 路卡号" style="margin-top: 16px" />
+          <a-empty v-else-if="!readingCard" description="设置卡号后点击「读取全部卡号」验证结果" style="margin-top: 16px" />
         </a-card>
       </a-col>
 
       <a-col :xs="24" :lg="14">
-        <a-card title="24 路 LED 实时状态">
+        <a-card title="24 路端口卡号状态">
           <a-alert
             v-if="!dataStore.serialConnected"
             message="串口未连接"
@@ -118,27 +112,42 @@
             show-icon
             style="margin-bottom: 16px"
           />
-          <div class="led-grid">
+          <div class="port-grid">
             <div
               v-for="n in 24"
               :key="n"
-              class="led-cell"
-              :class="{ active: selectedLed === n || selectedLed === 0 }"
-              @click="selectedLed = n"
+              class="port-cell"
+              :class="{ active: selectedPort === n }"
+              @click="selectedPort = n"
             >
-              <div
-                class="led-dot"
-                :style="{ background: ledColors[currentLedStates[n] ?? 0] }"
-                :title="`LED ${n}: ${statusText[currentLedStates[n] ?? 0]}`"
-              >
-                <span class="led-label">{{ n }}</span>
+              <div class="port-dot" :class="portClass(n)">
+                <span class="port-label">{{ n }}</span>
+              </div>
+              <div class="port-info">
+                <div class="port-status-text">{{ portStatusText(n) }}</div>
+                <div class="port-uid-text">{{ portUidText(n) }}</div>
               </div>
             </div>
           </div>
           <div class="legend">
-            <span v-for="(c, i) in ['关闭','红色闪烁','绿色闪烁','蓝色闪烁']" :key="i" class="legend-item">
-              <span class="legend-dot" :style="{ background: ledColors[i] }"></span> {{ c }}
+            <span class="legend-item">
+              <span class="legend-dot present"></span> 有卡
+            </span>
+            <span class="legend-item">
+              <span class="legend-dot empty"></span> 无卡
             </span>
+            <span class="legend-item">
+              <span class="legend-dot unknown"></span> 未读取
+            </span>
+          </div>
+          <div style="margin-top: 16px; color: #999; font-size: 13px; line-height: 1.8">
+            <p>使用说明:</p>
+            <ol>
+              <li>选择终端地址(从已配置设备列表加载)</li>
+              <li>选择要设置卡号的端口(P1 ~ P24)</li>
+              <li>输入 12 位十六进制卡号,点击「设置卡号」</li>
+              <li>点击「读取全部卡号」验证硬件是否写入成功</li>
+            </ol>
           </div>
         </a-card>
       </a-col>
@@ -153,7 +162,7 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
+import { ref, computed, onMounted } from 'vue'
 import apiService from '../api/apiService'
 import { useDataStore } from '../stores/dataStore'
 import SerialDataDisplay from '../components/SerialDataDisplay.vue'
@@ -168,92 +177,95 @@ function formatUid(uid) {
 }
 
 const selectedDevice = ref(undefined)
-const selectedLed = ref(1)
+const selectedPort = ref(1)
 const terminals = ref([])
-const loading = ref(-1)
-const discovering = ref(false)
-const feedbackMsg = ref('')
-const feedbackType = ref('success')
-// LED 状态按设备地址组织: { deviceAddress: { portId: color } }
-const ledStates = ref({})
+const cardUidInput = ref('')
+const setting = ref(false)
 const readingCard = ref(false)
 const cardList = ref([])
+const feedbackMsg = ref('')
+const feedbackType = ref('success')
+
 const cardColumns = [
   { title: '端口', key: 'antenna', dataIndex: 'antenna', width: 70 },
   { title: '状态', key: 'present', dataIndex: 'present', width: 90 },
   { title: '卡号', key: 'card_str', dataIndex: 'card_str' }
 ]
-let pollTimer = null
 
-const ledColors = { 0: '#d9d9d9', 1: '#ff4d4f', 2: '#52c41a', 3: '#1890ff' }
-const statusText = { 0: '关闭', 1: '红色闪烁', 2: '绿色闪烁', 3: '蓝色闪烁' }
-const colorNames = { 0: '关闭', 1: '红灯闪烁', 2: '绿灯闪烁', 3: '蓝灯闪烁' }
-
-const currentLedStates = computed(() => {
-  const addr = selectedDevice.value
-  if (addr === undefined) return {}
-  const raw = ledStates.value[addr] || {}
-  // 统一转换为数字,避免 undefined/字符串导致样式异常
-  const normalized = {}
-  for (let i = 1; i <= 24; i++) {
-    const v = raw[i]
-    normalized[i] = (v === undefined || v === null || v === '') ? 0 : Math.max(0, Math.min(3, Number(v) || 0))
-  }
-  return normalized
+const canControl = computed(() => terminals.value.length > 0)
+const canSet = computed(() => {
+  if (!canControl.value) return false
+  const uid = (cardUidInput.value || '').replace(/[:\s-]/g, '')
+  return uid.length === 12 && /^[0-9A-Fa-f]{12}$/.test(uid)
 })
 
-const canControl = computed(() => terminals.value.length > 0)
+// 端口状态网格
+function portClass(n) {
+  const card = cardList.value.find(c => c.antenna === n)
+  if (!card) return 'unknown'
+  return card.present ? 'present' : 'empty'
+}
+function portStatusText(n) {
+  const card = cardList.value.find(c => c.antenna === n)
+  if (!card) return '未读取'
+  return card.present ? '有卡' : '无卡'
+}
+function portUidText(n) {
+  const card = cardList.value.find(c => c.antenna === n)
+  if (!card || !card.present) return ''
+  return formatUid(card.card_str)
+}
 
-async function setColor(color) {
-  loading.value = color
+function showFeedback(msg, type = 'success') {
+  feedbackMsg.value = msg
+  feedbackType.value = type
+  setTimeout(() => { feedbackMsg.value = '' }, 4000)
+}
+
+async function setCard() {
+  const uid = (cardUidInput.value || '').replace(/[:\s-]/g, '').toUpperCase()
+  if (!/^[0-9A-F]{12}$/.test(uid)) {
+    showFeedback('卡号必须是 12 位十六进制', 'error')
+    return
+  }
+  setting.value = true
   feedbackMsg.value = ''
-  const params = { device_address: selectedDevice.value, color }
   try {
-    let res
-    if (selectedLed.value === 0) {
-      res = await apiService.modbus.setAllLeds(params)
-    } else {
-      res = await apiService.modbus.setRgbLed({ ...params, led_number: selectedLed.value })
-    }
+    const res = await apiService.modbus.setCardMapping({
+      device_address: selectedDevice.value,
+      port_id: selectedPort.value,
+      card_uid: uid
+    })
     if (res.data.success) {
-      const target = selectedLed.value === 0 ? '全部灯' : `LED ${selectedLed.value}`
-      feedbackMsg.value = `${target} → ${colorNames[color]}`
-      feedbackType.value = 'success'
-      // 按设备地址更新本地状态
-      const addr = selectedDevice.value
-      if (!ledStates.value[addr]) ledStates.value[addr] = {}
-      if (selectedLed.value === 0) {
-        for (let i = 1; i <= 24; i++) ledStates.value[addr][i] = color
-      } else {
-        ledStates.value[addr][selectedLed.value] = color
-      }
-      // force reactivity
-      ledStates.value = { ...ledStates.value }
+      showFeedback(`端口 P${selectedPort.value} 卡号已下发硬件: ${uid}`, 'success')
     } else {
-      feedbackMsg.value = res.data.message || '失败'
-      feedbackType.value = 'error'
+      showFeedback('设置失败: ' + (res.data.message || '未知错误'), 'error')
     }
   } catch (e) {
-    feedbackMsg.value = '错误: ' + (e.response?.data?.message || e.message)
-    feedbackType.value = 'error'
+    showFeedback('设置错误: ' + (e.response?.data?.message || e.message), 'error')
   } finally {
-    loading.value = -1
-    setTimeout(() => { feedbackMsg.value = '' }, 3000)
+    setting.value = false
   }
 }
 
-async function discoverTerminals() {
-  discovering.value = true
+async function clearCard() {
+  setting.value = true
+  feedbackMsg.value = ''
   try {
-    const res = await apiService.modbus.broadcastQuery({ timeout: 3 })
-    if (res.data.success && res.data.responses) {
-      terminals.value = res.data.responses.map((r, i) => ({
-        uid: r.uid, uid_readable: r.uid_readable, address: i + 1
-      }))
-      if (terminals.value.length) selectedDevice.value = terminals.value[0].address
+    const res = await apiService.modbus.setCardMapping({
+      device_address: selectedDevice.value,
+      port_id: selectedPort.value,
+      card_uid: null
+    })
+    if (res.data.success) {
+      showFeedback(`端口 P${selectedPort.value} 卡号已清除(填 0xFF)`, 'success')
+    } else {
+      showFeedback('清除失败: ' + (res.data.message || '未知错误'), 'error')
     }
+  } catch (e) {
+    showFeedback('清除错误: ' + (e.response?.data?.message || e.message), 'error')
   } finally {
-    discovering.value = false
+    setting.value = false
   }
 }
 
@@ -288,94 +300,85 @@ async function readAllCards() {
         uid: c.uid || ''
       }))
       const has = cardList.value.filter(c => c.present).length
-      feedbackMsg.value = `读取成功,24 路中 ${has} 路有卡`
-      feedbackType.value = 'success'
+      showFeedback(`读取成功,24 路中 ${has} 路有卡`, 'success')
     } else {
-      feedbackMsg.value = '读卡失败: ' + (res.data.message || '未知错误')
-      feedbackType.value = 'error'
+      showFeedback('读卡失败: ' + (res.data.message || '未知错误'), 'error')
     }
   } catch (e) {
-    feedbackMsg.value = '读卡错误: ' + (e.response?.data?.message || e.message)
-    feedbackType.value = 'error'
+    showFeedback('读卡错误: ' + (e.response?.data?.message || e.message), 'error')
   } finally {
     readingCard.value = false
-    setTimeout(() => { feedbackMsg.value = '' }, 3000)
   }
 }
 
-async function refreshStatus() {
-  try {
-    const addr = selectedDevice.value
-    if (addr === undefined) return
-    const ledRes = await apiService.modbus.getLedStatus(addr)
-    if (ledRes.data.success && ledRes.data.leds) {
-      ledStates.value[addr] = { ...ledRes.data.leds }
-      ledStates.value = { ...ledStates.value }
-    }
-  } catch (e) { /* ignore */ }
-}
-
-watch(selectedDevice, (newAddr) => {
-  if (newAddr !== undefined) refreshStatus()
-})
-
 onMounted(() => {
   loadTerminals()
-  refreshStatus()
-  pollTimer = setInterval(refreshStatus, 3000)
-})
-
-onUnmounted(() => {
-  if (pollTimer) clearInterval(pollTimer)
 })
 </script>
 
 <style scoped>
-.led-debug {
+.card-debug {
   width: 100%;
 }
-.led-grid {
+.port-grid {
   display: grid;
-  grid-template-columns: repeat(6, 1fr);
+  grid-template-columns: repeat(4, 1fr);
   gap: 12px;
 }
 @media (max-width: 768px) {
-  .led-grid { grid-template-columns: repeat(4, 1fr); }
+  .port-grid { grid-template-columns: repeat(3, 1fr); }
 }
 @media (max-width: 480px) {
-  .led-grid { grid-template-columns: repeat(2, 1fr); }
+  .port-grid { grid-template-columns: repeat(2, 1fr); }
 }
-.led-cell {
+.port-cell {
   display: flex;
-  justify-content: center;
+  align-items: center;
+  gap: 10px;
   cursor: pointer;
-  padding: 4px;
-  border-radius: 12px;
-  transition: box-shadow 0.2s;
+  padding: 8px;
+  border-radius: 10px;
+  border: 2px solid transparent;
+  transition: all 0.2s;
 }
-.led-cell.active {
-  box-shadow: 0 0 0 3px #1890ff;
+.port-cell.active {
+  border-color: #1890ff;
+  background: #e6f7ff;
 }
-.led-dot {
-  width: 64px;
-  height: 64px;
-  border-radius: 12px;
+.port-dot {
+  width: 48px;
+  height: 48px;
+  border-radius: 10px;
   display: flex;
   align-items: center;
   justify-content: center;
   border: 2px solid rgba(0,0,0,0.1);
-  transition: background 0.3s;
-}
-.led-dot:hover {
-  transform: translateY(-2px);
-  box-shadow: 0 4px 12px rgba(0,0,0,0.2);
+  flex-shrink: 0;
 }
-.led-label {
-  font-size: 18px;
+.port-dot.present { background: #52c41a; }
+.port-dot.empty { background: #d9d9d9; }
+.port-dot.unknown { background: #bfbfbf; }
+.port-label {
+  font-size: 16px;
   font-weight: 700;
   color: #fff;
   text-shadow: 0 1px 3px rgba(0,0,0,0.4);
 }
+.port-info {
+  flex: 1;
+  min-width: 0;
+}
+.port-status-text {
+  font-size: 13px;
+  font-weight: 600;
+  color: #333;
+}
+.port-uid-text {
+  font-size: 11px;
+  font-family: 'Monaco', 'Menlo', monospace;
+  color: #666;
+  word-break: break-all;
+}
 .legend {
   display: flex;
   gap: 20px;
@@ -392,9 +395,12 @@ onUnmounted(() => {
 .legend-dot {
   width: 14px;
   height: 14px;
-  border-radius: 50%;
+  border-radius: 4px;
   border: 1px solid rgba(0,0,0,0.1);
 }
+.legend-dot.present { background: #52c41a; }
+.legend-dot.empty { background: #d9d9d9; }
+.legend-dot.unknown { background: #bfbfbf; }
 .card-uid {
   font-family: 'Monaco', 'Menlo', monospace;
   font-size: 13px;

+ 44 - 22
frontend/src/views/PortStatus.vue

@@ -159,29 +159,31 @@
       <!-- 事件历史 -->
       <a-col :xs="24" :lg="10">
         <a-card title="事件历史">
-          <a-empty v-if="events.length === 0" description="暂无事件" />
-          <a-timeline v-else>
-            <a-timeline-item
-              v-for="(event, index) in recentEvents"
-              :key="index"
-              :color="getEventColor(event.event_type)"
-            >
-              <div class="event-item">
-                <div class="event-header">
-                  <a-tag :color="getEventTagColor(event.event_type)" size="small">
-                    {{ eventTypeText[event.event_type] || event.event_type }}
-                  </a-tag>
-                  <span class="event-time">{{ formatTime(event.timestamp) }}</span>
-                </div>
-                <div class="event-detail">
-                  面板 {{ event.panel_id }} 端口 {{ event.port_id }}
-                </div>
-                <div class="event-uid">
-                  UID: {{ formatUid(event.jumper_uid) }}
+          <div class="event-scroll">
+            <a-empty v-if="events.length === 0" description="暂无事件" />
+            <a-timeline v-else>
+              <a-timeline-item
+                v-for="(event, index) in recentEvents"
+                :key="index"
+                :color="getEventColor(event.event_type)"
+              >
+                <div class="event-item">
+                  <div class="event-header">
+                    <a-tag :color="getEventTagColor(event.event_type)" size="small">
+                      {{ eventTypeText[event.event_type] || event.event_type }}
+                    </a-tag>
+                    <span class="event-time">{{ formatTime(event.timestamp) }}</span>
+                  </div>
+                  <div class="event-detail">
+                    面板 {{ event.panel_id }} 端口 {{ event.port_id }}
+                  </div>
+                  <div class="event-uid">
+                    UID: {{ formatUid(event.jumper_uid) }}
+                  </div>
                 </div>
-              </div>
-            </a-timeline-item>
-          </a-timeline>
+              </a-timeline-item>
+            </a-timeline>
+          </div>
         </a-card>
       </a-col>
     </a-row>
@@ -575,6 +577,26 @@ onUnmounted(() => {
 .event-item {
   padding: 4px 0;
 }
+.event-scroll {
+  max-height: 600px;
+  overflow-y: auto;
+  padding-right: 8px;
+}
+/* 滚动条样式 */
+.event-scroll::-webkit-scrollbar {
+  width: 6px;
+}
+.event-scroll::-webkit-scrollbar-track {
+  background: #f0f0f0;
+  border-radius: 3px;
+}
+.event-scroll::-webkit-scrollbar-thumb {
+  background: #bbb;
+  border-radius: 3px;
+}
+.event-scroll::-webkit-scrollbar-thumb:hover {
+  background: #999;
+}
 .event-header {
   display: flex;
   align-items: center;

+ 40 - 19
frontend/src/views/RealTimeStatus.vue

@@ -31,25 +31,27 @@
       <!-- 终端列表 -->
       <a-col :xs="24" :lg="8">
         <a-card title="已发现终端">
-          <a-empty v-if="!dataStore.serialConnected" description="串口未连接" />
-          <a-empty v-else-if="terminals.length === 0" description="暂无终端" />
-          <a-list v-else size="small" :data-source="terminals">
-            <template #renderItem="{ item }">
-              <a-list-item
-                :class="['terminal-item', { 'terminal-item-selected': selectedTerminal?.address === item.address }]"
-                @click="selectTerminal(item)"
-              >
-                <a-list-item-meta>
-                  <template #title>
-                    <span>地址 {{ item.address }}</span>
-                  </template>
-                  <template #description>
-                    <code class="uid-text">{{ item.uid_readable || item.uid }}</code>
-                  </template>
-                </a-list-item-meta>
-              </a-list-item>
-            </template>
-          </a-list>
+          <div class="terminal-scroll">
+            <a-empty v-if="!dataStore.serialConnected" description="串口未连接" />
+            <a-empty v-else-if="terminals.length === 0" description="暂无终端" />
+            <a-list v-else size="small" :data-source="terminals">
+              <template #renderItem="{ item }">
+                <a-list-item
+                  :class="['terminal-item', { 'terminal-item-selected': selectedTerminal?.address === item.address }]"
+                  @click="selectTerminal(item)"
+                >
+                  <a-list-item-meta>
+                    <template #title>
+                      <span>地址 {{ item.address }}</span>
+                    </template>
+                    <template #description>
+                      <code class="uid-text">{{ item.uid_readable || item.uid }}</code>
+                    </template>
+                  </a-list-item-meta>
+                </a-list-item>
+              </template>
+            </a-list>
+          </div>
         </a-card>
       </a-col>
 
@@ -302,6 +304,25 @@ onUnmounted(() => {
   cursor: pointer;
   transition: background-color 0.2s ease;
 }
+.terminal-scroll {
+  max-height: 600px;
+  overflow-y: auto;
+  padding-right: 8px;
+}
+.terminal-scroll::-webkit-scrollbar {
+  width: 6px;
+}
+.terminal-scroll::-webkit-scrollbar-track {
+  background: #f0f0f0;
+  border-radius: 3px;
+}
+.terminal-scroll::-webkit-scrollbar-thumb {
+  background: #bbb;
+  border-radius: 3px;
+}
+.terminal-scroll::-webkit-scrollbar-thumb:hover {
+  background: #999;
+}
 .terminal-item:hover {
   background-color: #f5f5f5;
 }

+ 8 - 0
tools/rkssh/go.mod

@@ -0,0 +1,8 @@
+module rkssh
+
+go 1.22
+
+require (
+	golang.org/x/crypto v0.25.0
+	golang.org/x/sys v0.22.0
+)

+ 6 - 0
tools/rkssh/go.sum

@@ -0,0 +1,6 @@
+golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
+golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
+golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=

+ 329 - 0
tools/rkssh/main.go

@@ -0,0 +1,329 @@
+package main
+
+import (
+	"crypto/ed25519"
+	"crypto/rand"
+	"encoding/pem"
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"net"
+	"os"
+	"os/exec"
+	"sync"
+	"syscall"
+
+	"golang.org/x/crypto/ssh"
+	"golang.org/x/sys/unix"
+)
+
+type ptyRequest struct {
+	Term  string
+	Cols  uint32
+	Rows  uint32
+	Wpx   uint32
+	Hpx   uint32
+	Modes string
+}
+
+type winChange struct {
+	Cols uint32
+	Rows uint32
+	Wpx  uint32
+	Hpx  uint32
+}
+
+type execRequest struct{ Command string }
+
+type envRequest struct{ Name, Value string }
+
+type session struct {
+	ch     ssh.Channel
+	reqs   <-chan *ssh.Request
+	shell  string
+	env    []string
+	hasPty bool
+	cols   uint32
+	rows   uint32
+	mu     sync.Mutex
+	master *os.File
+}
+
+func main() {
+	addr := flag.String("addr", "0.0.0.0:22", "listen address")
+	user := flag.String("user", "root", "allowed username")
+	pass := flag.String("pass", "123456", "password")
+	hostKeyPath := flag.String("hostkey", "/data/dropbear/go_hostkey", "host key file")
+	shell := flag.String("shell", "/system/bin/sh", "login shell")
+	flag.Parse()
+
+	signer, err := loadOrCreateHostKey(*hostKeyPath)
+	if err != nil {
+		log.Fatalf("host key: %v", err)
+	}
+
+	cfg := &ssh.ServerConfig{
+		PasswordCallback: func(c ssh.ConnMetadata, p []byte) (*ssh.Permissions, error) {
+			if c.User() == *user && string(p) == *pass {
+				return nil, nil
+			}
+			return nil, fmt.Errorf("password rejected for %q", c.User())
+		},
+	}
+	cfg.AddHostKey(signer)
+
+	ln, err := net.Listen("tcp", *addr)
+	if err != nil {
+		log.Fatalf("listen %s: %v", *addr, err)
+	}
+	log.Printf("rkssh listening on %s (user=%s shell=%s)", *addr, *user, *shell)
+
+	for {
+		conn, err := ln.Accept()
+		if err != nil {
+			log.Printf("accept: %v", err)
+			continue
+		}
+		go handleConn(conn, cfg, *shell)
+	}
+}
+
+func handleConn(nConn net.Conn, cfg *ssh.ServerConfig, shell string) {
+	sshConn, chans, reqs, err := ssh.NewServerConn(nConn, cfg)
+	if err != nil {
+		log.Printf("handshake from %s failed: %v", nConn.RemoteAddr(), err)
+		nConn.Close()
+		return
+	}
+	log.Printf("login: %s from %s", sshConn.User(), sshConn.RemoteAddr())
+	defer sshConn.Close()
+	go ssh.DiscardRequests(reqs)
+
+	for newChan := range chans {
+		if newChan.ChannelType() != "session" {
+			newChan.Reject(ssh.UnknownChannelType, "unknown channel type")
+			continue
+		}
+		ch, chReqs, err := newChan.Accept()
+		if err != nil {
+			continue
+		}
+		s := &session{ch: ch, reqs: chReqs, shell: shell, cols: 80, rows: 24}
+		go s.run()
+	}
+}
+
+func (s *session) run() {
+	defer s.ch.Close()
+	started := false
+
+	for req := range s.reqs {
+		switch req.Type {
+		case "pty-req":
+			var p ptyRequest
+			if err := ssh.Unmarshal(req.Payload, &p); err != nil {
+				req.Reply(false, nil)
+				continue
+			}
+			s.mu.Lock()
+			s.hasPty = true
+			if p.Cols > 0 {
+				s.cols = p.Cols
+			}
+			if p.Rows > 0 {
+				s.rows = p.Rows
+			}
+			s.mu.Unlock()
+			if p.Term != "" {
+				s.env = append(s.env, "TERM="+p.Term)
+			}
+			req.Reply(true, nil)
+
+		case "window-change":
+			var w winChange
+			if err := ssh.Unmarshal(req.Payload, &w); err == nil {
+				s.resize(w.Cols, w.Rows)
+			}
+			req.Reply(false, nil)
+
+		case "env":
+			var e envRequest
+			if err := ssh.Unmarshal(req.Payload, &e); err == nil {
+				s.env = append(s.env, e.Name+"="+e.Value)
+				req.Reply(true, nil)
+			} else {
+				req.Reply(false, nil)
+			}
+
+		case "shell":
+			if started {
+				req.Reply(false, nil)
+				continue
+			}
+			started = true
+			req.Reply(true, nil)
+			go s.exec()
+
+		case "exec":
+			if started {
+				req.Reply(false, nil)
+				continue
+			}
+			var e execRequest
+			if err := ssh.Unmarshal(req.Payload, &e); err != nil {
+				req.Reply(false, nil)
+				continue
+			}
+			started = true
+			req.Reply(true, nil)
+			go s.exec("-c", e.Command)
+
+		default:
+			req.Reply(false, nil)
+		}
+	}
+}
+
+func (s *session) resize(cols, rows uint32) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if cols > 0 {
+		s.cols = cols
+	}
+	if rows > 0 {
+		s.rows = rows
+	}
+	if s.master != nil {
+		_ = unix.IoctlSetWinsize(int(s.master.Fd()), unix.TIOCSWINSZ,
+			&unix.Winsize{Row: uint16(s.rows), Col: uint16(s.cols)})
+	}
+}
+
+func (s *session) exec(shellArgs ...string) {
+	s.mu.Lock()
+	hasPty, cols, rows := s.hasPty, s.cols, s.rows
+	s.mu.Unlock()
+
+	cmd := exec.Command(s.shell, shellArgs...)
+	cmd.Env = append(defaultEnv(), s.env...)
+	cmd.Dir = "/data"
+	status := uint32(0)
+
+	if hasPty {
+		master, slave, err := openPty(cols, rows)
+		if err != nil {
+			fmt.Fprintf(s.ch, "rkssh: pty error: %v\r\n", err)
+			s.exit(1)
+			return
+		}
+		s.mu.Lock()
+		s.master = master
+		s.mu.Unlock()
+		cmd.Stdin, cmd.Stdout, cmd.Stderr = slave, slave, slave
+		cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
+		if err := cmd.Start(); err != nil {
+			fmt.Fprintf(s.ch, "rkssh: start error: %v\r\n", err)
+			master.Close()
+			slave.Close()
+			s.exit(1)
+			return
+		}
+		slave.Close()
+		go func() { io.Copy(master, s.ch); master.Close() }()
+		io.Copy(s.ch, master)
+		if err := cmd.Wait(); err != nil {
+			status = exitCode(err)
+		}
+		s.exit(status)
+		return
+	}
+
+	stdin, _ := cmd.StdinPipe()
+	stdout, _ := cmd.StdoutPipe()
+	cmd.Stderr = cmd.Stdout
+	if err := cmd.Start(); err != nil {
+		fmt.Fprintf(s.ch, "rkssh: start error: %v\n", err)
+		s.exit(1)
+		return
+	}
+	go func() { io.Copy(stdin, s.ch); stdin.Close() }()
+	io.Copy(s.ch, stdout)
+	if err := cmd.Wait(); err != nil {
+		status = exitCode(err)
+	}
+	s.exit(status)
+}
+
+func (s *session) exit(code uint32) {
+	s.ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{code}))
+	s.ch.Close()
+}
+
+func exitCode(err error) uint32 {
+	if ee, ok := err.(*exec.ExitError); ok {
+		if ws, ok := ee.Sys().(syscall.WaitStatus); ok {
+			return uint32(ws.ExitStatus())
+		}
+	}
+	return 1
+}
+
+func defaultEnv() []string {
+	return []string{
+		"PATH=/system/bin:/system/xbin:/vendor/bin:/data/dropbear:/data/local/tmp",
+		"HOME=/data",
+		"TERM=xterm-256color",
+		"LANG=C.UTF-8",
+	}
+}
+
+func openPty(cols, rows uint32) (*os.File, *os.File, error) {
+	if cols == 0 {
+		cols = 80
+	}
+	if rows == 0 {
+		rows = 24
+	}
+	master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0)
+	if err != nil {
+		return nil, nil, err
+	}
+	if err := unix.IoctlSetPointerInt(int(master.Fd()), unix.TIOCSPTLCK, 0); err != nil {
+		master.Close()
+		return nil, nil, err
+	}
+	n, err := unix.IoctlGetInt(int(master.Fd()), unix.TIOCGPTN)
+	if err != nil {
+		master.Close()
+		return nil, nil, err
+	}
+	_ = unix.IoctlSetWinsize(int(master.Fd()), unix.TIOCSWINSZ,
+		&unix.Winsize{Row: uint16(rows), Col: uint16(cols)})
+	slave, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", n), os.O_RDWR|syscall.O_NOCTTY, 0)
+	if err != nil {
+		master.Close()
+		return nil, nil, err
+	}
+	return master, slave, nil
+}
+
+func loadOrCreateHostKey(path string) (ssh.Signer, error) {
+	if data, err := os.ReadFile(path); err == nil {
+		if signer, err := ssh.ParsePrivateKey(data); err == nil {
+			return signer, nil
+		}
+	}
+	_, priv, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		return nil, err
+	}
+	block, err := ssh.MarshalPrivateKey(priv, "rkssh")
+	if err != nil {
+		return nil, err
+	}
+	if err := os.WriteFile(path, pem.EncodeToMemory(block), 0o600); err != nil {
+		log.Printf("warn: cannot persist host key %s: %v", path, err)
+	}
+	return ssh.NewSignerFromKey(priv)
+}

+ 5 - 0
tools/rkssh/rkssh.rc

@@ -0,0 +1,5 @@
+service rkssh /data/dropbear/rkssh -addr 0.0.0.0:22 -user root -pass 123456 -hostkey /data/dropbear/go_hostkey
+    class main
+    user root
+    group root
+    seclabel u:r:su:s0

+ 11 - 0
tools/rkssh/start.sh

@@ -0,0 +1,11 @@
+#!/system/bin/sh
+# Start the rkssh SSH server (port 22, user root). Run as root.
+pkill rkssh 2>/dev/null
+sleep 1
+setsid /data/dropbear/rkssh \
+    -addr 0.0.0.0:22 \
+    -user root \
+    -pass 123456 \
+    -hostkey /data/dropbear/go_hostkey \
+    >/data/dropbear/rkssh.log 2>&1 </dev/null &
+echo "rkssh started"

Некоторые файлы не были показаны из-за большого количества измененных файлов