Kaynağa Gözat

feat: 新增批量读卡功能,优化连接状态拉取逻辑

1. 新增modbus批量读取全部24路天线卡号的API和前端页面支持
2. 新增定时主动拉取MQTT和串口连接状态,修复初始加载无法获取状态的问题
3. 优化设备在线状态判定逻辑,改用设备地址判断
4. 新增调试用的脚本命令
wenhongquan 6 saat önce
ebeveyn
işleme
e0ba76a0e2

+ 7 - 1
.claude/settings.local.json

@@ -40,7 +40,13 @@
       "Bash(curl -s -m 8 -X POST http://192.168.199.149:5001/api/modbus/read_antenna -H 'Content-Type: application/json' -d '{\"device_address\":2,\"antenna\":17}')",
       "Bash(curl -s -m 10 -X POST http://192.168.199.149:5001/api/dtu/control -H 'Content-Type: application/json' -d '{\"command\":\"SYNC_ALL_MAPPING\",\"target\":\"all\",\"params\":{\"mappings\":[{\"panel_id\":2,\"port_id\":17,\"jumper_uid\":\"CFB88E295301\"},{\"panel_id\":3,\"port_id\":10,\"jumper_uid\":\"DDAD57F95001\"}]}}')",
       "Bash(curl -s -m 8 -X POST http://192.168.199.149:5001/api/dtu/control -H 'Content-Type: application/json' -d '{\"command\":\"SYNC_PORT_MAPPING\",\"params\":{\"target\":\"port\",\"panel_id\":3,\"port_id\":10,\"jumper_uid\":\"DDAD57F95001\"}}')",
-      "Bash(curl -s -m 8 -X POST http://192.168.199.149:5001/api/dtu/control -H 'Content-Type: application/json' -d '{\"command\":\"SYNC_ALL_MAPPING\",\"params\":{\"target\":\"port\",\"mappings\":[{\"panel_id\":3,\"port_id\":10,\"jumper_uid\":\"DDAD57F95001\"}]}}')"
+      "Bash(curl -s -m 8 -X POST http://192.168.199.149:5001/api/dtu/control -H 'Content-Type: application/json' -d '{\"command\":\"SYNC_ALL_MAPPING\",\"params\":{\"target\":\"port\",\"mappings\":[{\"panel_id\":3,\"port_id\":10,\"jumper_uid\":\"DDAD57F95001\"}]}}')",
+      "Bash(/Users/wenhongquan/miniforge3/bin/python -c \"import ast; ast.parse\\(open\\('backend/app.py'\\).read\\(\\)\\); print\\('app.py 语法 OK'\\)\")",
+      "Bash(curl -s -m 8 -X POST http://192.168.199.149:5001/api/modbus/read_all_cards -H 'Content-Type: application/json' -d '{\"device_address\":2}')",
+      "Bash(curl -s http://192.168.199.149:5001/api/modbus/stored_devices)",
+      "Bash(curl -s http://192.168.199.149:5001/api/panel/status)",
+      "Bash(curl -s -m 8 -X POST http://192.168.199.149:5001/api/modbus/read_all_cards -H 'Content-Type: application/json' -d '{\"device_address\":3}')",
+      "Bash(/Users/wenhongquan/miniforge3/bin/python -c \"import ast; ast.parse\\(open\\('backend/app.py'\\).read\\(\\)\\); print\\('语法 OK'\\)\")"
     ]
   }
 }

+ 57 - 5
backend/app.py

@@ -2737,6 +2737,52 @@ def modbus_read_antenna():
         }), 500
 
 
+@app.route('/api/modbus/read_all_cards', methods=['POST'])
+def modbus_read_all_cards():
+    """V4: 一次批量读取全部 24 路天线卡号
+
+    请求参数:
+    {
+        "device_address": 2,
+        "timeout": 1.0
+    }
+    返回:
+    {
+        "success": true,
+        "data": {
+            "device_address": 2,
+            "cards": [
+                {"antenna": 1, "present": false, "card_str": "", "uid": ""},
+                {"antenna": 2, "present": true, "card_str": "aabbccddeeff", "uid": "aa:bb:cc:dd:ee:ff"},
+                ...
+            ]
+        }
+    }
+    """
+    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
+
+        result = modbus_client.read_all_antenna_cards(device_address, timeout=timeout)
+
+        if 'error' in result:
+            return jsonify({
+                'success': False,
+                'message': result['error'],
+                'raw_data': result.get('raw_data', '')
+            }), 400
+
+        return jsonify({'success': True, 'data': result})
+
+    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():
     """读取保持寄存器
@@ -3057,19 +3103,21 @@ def get_stored_devices():
     devices = address_config.get_stored_devices()
     now = time.time()
     online_threshold = 60  # 60 秒内被轮询到视为在线
-    online_uids = set()
+
+    # 建立 address -> online 映射(按 panel_config + device_last_seen 判定,兼容 panel_uid 缺失)
+    online_addrs = set()
     for pid, cfg in panel_config.items():
         if device_last_seen.get(pid, 0) > now - online_threshold:
-            uid = cfg.get('panel_uid')
-            if uid:
-                online_uids.add(uid)
+            addr = cfg.get('address')
+            if addr:
+                online_addrs.add(addr)
 
     panels = []
     for uid, addr in devices.items():
         panels.append({
             'uid': uid,
             'address': addr,
-            'online': uid in online_uids
+            'online': addr in online_addrs
         })
 
     return jsonify({
@@ -4286,6 +4334,10 @@ 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)

+ 22 - 2
frontend/src/App.vue

@@ -100,10 +100,11 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, watch } from 'vue'
+import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
 import { useRoute } from 'vue-router'
 import { useDataStore } from './stores/dataStore'
 import { initWebSocket } from './utils/websocket'
+import apiService from './api/apiService'
 import zhCN from 'ant-design-vue/es/locale/zh_CN'
 
 // 初始化数据存储
@@ -112,6 +113,19 @@ const route = useRoute()
 const locale = zhCN
 const collapsed = ref(false)
 
+// 主动拉取 MQTT / 串口状态(SocketIO 事件仅在状态变化时触发,页面加载时若已连接收不到事件)
+let statusTimer = null
+async function refreshConnStatus() {
+  try {
+    const [mqttRes, serialRes] = await Promise.all([
+      apiService.mqtt.getStatus(),
+      apiService.serial.getStatus()
+    ])
+    if (mqttRes.data) dataStore.setMQTTConnected(!!mqttRes.data.connected)
+    if (serialRes.data) dataStore.setSerialConnected(!!serialRes.data.connected)
+  } catch (e) { /* ignore */ }
+}
+
 // 计算当前选中的菜单项
 const selectedKey = computed(() => {
   return route.path
@@ -127,9 +141,15 @@ watch(() => route.path, (newPath) => {
   console.log('路由变化:', newPath)
 })
 
-// 组件挂载时初始化WebSocket连接
+// 组件挂载时初始化WebSocket连接 + 主动拉取连接状态
 onMounted(() => {
   initWebSocket(dataStore)
+  refreshConnStatus()
+  statusTimer = setInterval(refreshConnStatus, 5000)
+})
+
+onUnmounted(() => {
+  if (statusTimer) clearInterval(statusTimer)
 })
 </script>
 

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

@@ -66,6 +66,7 @@ const apiService = {
   modbus: {
     getAntennaAddresses: () => apiClient.get('/modbus/antenna_addresses'),
     readAntenna: (params) => apiClient.post('/modbus/read_antenna', params),
+    readAllCards: (params) => apiClient.post('/modbus/read_all_cards', 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),

+ 57 - 30
frontend/src/views/LedDebug.vue

@@ -77,26 +77,35 @@
           />
         </a-card>
 
-        <a-card title="读卡号" style="margin-top: 16px">
-          <a-form layout="vertical">
-            <a-form-item label="选择天线 (1-24)">
-              <a-space>
-                <a-input-number v-model:value="cardAntenna" :min="1" :max="24" style="width: 120px" />
-                <a-button type="primary" @click="readCardNumber" :loading="readingCard" :disabled="!canControl">
-                  读取卡号
-                </a-button>
-              </a-space>
-            </a-form-item>
-          </a-form>
+        <a-card title="读卡号(V4 批量 24 路)" style="margin-top: 16px">
+          <a-button type="primary" @click="readAllCards" :loading="readingCard" :disabled="!canControl">
+            读取全部卡号
+          </a-button>
 
-          <div v-if="cardResult" class="card-result">
-            <a-descriptions bordered size="small" :column="1">
-              <a-descriptions-item label="天线位置">{{ cardResult.antenna }}</a-descriptions-item>
-              <a-descriptions-item label="卡号">{{ formatUid(cardResult.card_number_str) }}</a-descriptions-item>
-              <a-descriptions-item label="原始数据">{{ cardResult.raw_data }}</a-descriptions-item>
-            </a-descriptions>
-          </div>
-          <a-empty v-else-if="!readingCard" description="点击「读取卡号」查看结果" />
+          <a-table
+            v-if="cardList.length > 0"
+            :data-source="cardList"
+            :columns="cardColumns"
+            :pagination="false"
+            size="small"
+            row-key="antenna"
+            style="margin-top: 12px"
+          >
+            <template #bodyCell="{ column, record }">
+              <template v-if="column.key === 'antenna'">
+                P{{ record.antenna }}
+              </template>
+              <template v-else-if="column.key === 'present'">
+                <a-tag :color="record.present ? 'green' : 'default'">
+                  {{ record.present ? '有卡' : '无卡' }}
+                </a-tag>
+              </template>
+              <template v-else-if="column.key === 'card_str'">
+                <span class="card-uid">{{ record.present ? formatUid(record.card_str) : '-' }}</span>
+              </template>
+            </template>
+          </a-table>
+          <a-empty v-else-if="!readingCard" description="点击「读取全部卡号」查看 24 路卡号" style="margin-top: 16px" />
         </a-card>
       </a-col>
 
@@ -115,7 +124,7 @@
               :key="n"
               class="led-cell"
               :class="{ active: selectedLed === n || selectedLed === 0 }"
-              @click="selectedLed = n; cardAntenna = n"
+              @click="selectedLed = n"
             >
               <div
                 class="led-dot"
@@ -167,9 +176,13 @@ const feedbackMsg = ref('')
 const feedbackType = ref('success')
 // LED 状态按设备地址组织: { deviceAddress: { portId: color } }
 const ledStates = ref({})
-const cardAntenna = ref(1)
 const readingCard = ref(false)
-const cardResult = ref(null)
+const cardList = ref([])
+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' }
@@ -259,16 +272,24 @@ async function loadTerminals() {
   } catch (e) { console.error(e) }
 }
 
-async function readCardNumber() {
+async function readAllCards() {
   readingCard.value = true
-  cardResult.value = null
+  cardList.value = []
   try {
-    const res = await apiService.modbus.readAntenna({
-      device_address: selectedDevice.value,
-      antenna: cardAntenna.value
+    const res = await apiService.modbus.readAllCards({
+      device_address: selectedDevice.value
     })
-    if (res.data.success && res.data.data) {
-      cardResult.value = res.data.data
+    if (res.data.success && res.data.data && res.data.data.cards) {
+      cardList.value = res.data.data.cards.map(c => ({
+        key: c.antenna,
+        antenna: c.antenna,
+        present: !!c.present,
+        card_str: c.card_str || '',
+        uid: c.uid || ''
+      }))
+      const has = cardList.value.filter(c => c.present).length
+      feedbackMsg.value = `读取成功,24 路中 ${has} 路有卡`
+      feedbackType.value = 'success'
     } else {
       feedbackMsg.value = '读卡失败: ' + (res.data.message || '未知错误')
       feedbackType.value = 'error'
@@ -278,7 +299,7 @@ async function readCardNumber() {
     feedbackType.value = 'error'
   } finally {
     readingCard.value = false
-    setTimeout(() => { if (!cardResult.value) feedbackMsg.value = '' }, 3000)
+    setTimeout(() => { feedbackMsg.value = '' }, 3000)
   }
 }
 
@@ -374,4 +395,10 @@ onUnmounted(() => {
   border-radius: 50%;
   border: 1px solid rgba(0,0,0,0.1);
 }
+.card-uid {
+  font-family: 'Monaco', 'Menlo', monospace;
+  font-size: 13px;
+  color: #333;
+  word-break: break-all;
+}
 </style>