From b970b78136007f88af8fa4cbae42bebc42eff4a9 Mon Sep 17 00:00:00 2001 From: default Date: Tue, 31 Mar 2026 05:01:04 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0UI?= =?UTF-8?q?=E5=85=A8=E9=9D=A2=E4=BC=98=E5=8C=96=20+=20TCP=E6=8C=87?= =?UTF-8?q?=E4=BB=A4=E5=8F=91=E9=80=81=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 指令页面:三个设备选择器合并为顶部统一选择器,在线/离线分组显示 - 仪表盘:8卡片压缩为6紧凑卡片,新增刷新按钮和更新时间戳 - 仪表盘:最近告警添加"查看全部"跳转链接 - 考勤页面:定位方式显示GPS/WiFi/LBS文字标签替代纯图标 - 告警页面:alarm_source中文化(单围栏/多围栏/基站/WiFi) - 告警页面:确认操作改为DOM单行更新,无需刷新整个表格 - 数据日志:默认选中"位置"类型 - 全部表格:全选复选框tooltip改为"全选当前页" - TCP修复:移除发送前硬性在线检查,改为尝试发送+失败提示 - TCP修复:检测僵死连接(writer.is_closing)并自动清理 via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- app/routers/commands.py | 17 +-- app/static/admin.html | 259 +++++++++++++++++++++------------------- app/tcp_server.py | 19 ++- 3 files changed, 162 insertions(+), 133 deletions(-) diff --git a/app/routers/commands.py b/app/routers/commands.py index 05e49c3..73bde34 100644 --- a/app/routers/commands.py +++ b/app/routers/commands.py @@ -103,12 +103,6 @@ async def _send_to_device( executor : async callable The actual send function, e.g. tcp_command_service.send_command(...) """ - if not tcp_command_service.is_device_online(device.imei): - raise HTTPException( - status_code=400, - detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线", - ) - command_log = await command_service.create_command( db, device_id=device.id, @@ -117,7 +111,7 @@ async def _send_to_device( ) try: - await executor() + result = await executor() except Exception as e: logging.getLogger(__name__).error("Command send failed: %s", e) command_log.status = "failed" @@ -128,6 +122,15 @@ async def _send_to_device( detail=fail_msg, ) + if result is False: + command_log.status = "failed" + await db.flush() + await db.refresh(command_log) + raise HTTPException( + status_code=400, + detail=f"Device {device.imei} TCP not connected / 设备 {device.imei} TCP未连接,请等待设备重连", + ) + command_log.status = "sent" command_log.sent_at = now_cst() await db.flush() diff --git a/app/static/admin.html b/app/static/admin.html index 4bad531..2820194 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -226,97 +226,37 @@

数据每 30 秒自动刷新,无需手动操作

-
-
-
-
-

设备总数

-

-

-
-
- -
-
-
-
-
-
-

在线设备

-

-

-
-
- -
-
-
-
-
-
-

离线设备

-

-

-
-
- -
-
-
-
-
-
-

已连接设备

-

-

-
-
- -
-
+
+
+
+
- -
-
-
-
-

告警总数

-

-

-
-
- -
-
+
+
+

设备总数

+

-

-
-
-
-

未确认告警

-

-

-
-
- -
-
+
+

在线

+

-

-
-
-
-

已确认告警

-

-

-
-
- -
-
+
+

离线

+

-

-
-
-
-

系统状态

-

-

-
-
- -
-
+
+

告警总数

+

-

+
+
+

未确认

+

-

+
+
+

系统

+

-

+
@@ -328,7 +268,10 @@
-

最近告警

+
+

最近告警

+ 查看全部 +

加载中...

@@ -468,7 +411,7 @@ - + @@ -561,7 +504,7 @@
设备ID 类型 纬度
- + @@ -645,7 +588,7 @@
设备ID 类型 来源
- + @@ -713,7 +656,7 @@
设备ID 类型 来源
- + @@ -902,7 +845,7 @@
设备ID 类型 信标MAC
- + @@ -980,15 +923,16 @@

所有下发功能仅对当前在线(TCP 已连接)设备有效,离线设备无法接收

+
+ + + +

发送指令

-
- - -
@@ -1001,12 +945,6 @@

发送消息

-
- - -
@@ -1015,12 +953,6 @@

语音播报

-
- - -
@@ -1204,7 +1136,7 @@ low_battery: '低电量', low_battery_protection: '低电保护', sim_change: 'SIM卡更换', power_off: '关机', airplane_mode: '飞行模式', remove: '拆除', - door: '门', shutdown: '关机', + door: '门', shutdown: '低电关机', voice_alarm: '声控报警', fake_base_station: '伪基站', cover_open: '开盖', internal_low_battery: '内部低电', acc_on: 'ACC开', acc_off: 'ACC关', @@ -1506,7 +1438,7 @@ const data = await apiCall(`${API_BASE}/devices?page=1&page_size=100`); const devices = data.items || []; cachedDevices = devices; - const selectors = ['locDeviceSelect', 'alarmDeviceFilter', 'attDeviceFilter', 'btDeviceFilter', 'cmdDeviceSelect', 'msgDeviceSelect', 'ttsDeviceSelect', 'cmdHistoryDeviceFilter']; + const selectors = ['locDeviceSelect', 'alarmDeviceFilter', 'attDeviceFilter', 'btDeviceFilter', 'cmdHistoryDeviceFilter']; selectors.forEach(id => { const sel = document.getElementById(id); if (!sel) return; @@ -1521,6 +1453,51 @@ }); if (currentVal) sel.value = currentVal; }); + // Unified command device selector with online/offline status + const cmdSel = document.getElementById('cmdUnifiedDevice'); + if (cmdSel) { + const curVal = cmdSel.value; + cmdSel.innerHTML = ''; + const online = devices.filter(d => d.status === 'online'); + const offline = devices.filter(d => d.status !== 'online'); + if (online.length) { + const grpOn = document.createElement('optgroup'); + grpOn.label = `在线 (${online.length})`; + online.forEach(d => { + const o = document.createElement('option'); + o.value = d.id || d.device_id || ''; + o.textContent = `🟢 ${d.name || d.imei} (${d.imei || ''})`; + grpOn.appendChild(o); + }); + cmdSel.appendChild(grpOn); + } + if (offline.length) { + const grpOff = document.createElement('optgroup'); + grpOff.label = `离线 (${offline.length})`; + offline.forEach(d => { + const o = document.createElement('option'); + o.value = d.id || d.device_id || ''; + o.textContent = `⚪ ${d.name || d.imei} (${d.imei || ''})`; + o.style.color = '#6b7280'; + grpOff.appendChild(o); + }); + cmdSel.appendChild(grpOff); + } + if (curVal) cmdSel.value = curVal; + // Update status hint on change + cmdSel.onchange = () => { + const st = document.getElementById('cmdDeviceStatus'); + if (!st) return; + const did = cmdSel.value; + if (!did) { st.textContent = ''; return; } + const dev = devices.find(d => String(d.id) === did || String(d.device_id) === did); + if (dev && dev.status === 'online') { + st.innerHTML = '在线 - 可下发'; + } else { + st.innerHTML = '离线 - 无法接收指令'; + } + }; + } // Render device panel on locations page if (currentPage === 'locations' && document.getElementById('locPanelList')) { const sorted = sortDevicesByActivity(devices); @@ -1556,7 +1533,6 @@ const as = alarmStats.value; animateCounter('dashTotalAlarms', as.total || 0); animateCounter('dashUnackAlarms', as.unacknowledged || 0); - animateCounter('dashAckAlarms', as.acknowledged || 0); renderAlarmDoughnut('dashAlarmChart', as.by_type, false); } @@ -1564,14 +1540,19 @@ const h = health.value; const status = h.status || 'unknown'; const connected = h.connected_devices || 0; - animateCounter('dashConnectedDevices', connected); document.getElementById('dashSystemStatus').textContent = status === 'ok' || status === 'healthy' ? '正常运行' : status; + const hint = document.getElementById('dashConnectedHint'); + if (hint) hint.textContent = `TCP ${connected} 台`; document.getElementById('healthStatus').innerHTML = `在线 (${connected}台)`; } else { document.getElementById('healthStatus').innerHTML = `离线`; document.getElementById('dashSystemStatus').textContent = '无法连接'; } + // Update last-refreshed timestamp + const upd = document.getElementById('dashLastUpdated'); + if (upd) upd.textContent = '更新于 ' + new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit',second:'2-digit'}); + // Load recent alarms try { const alarms = await apiCall(`${API_BASE}/alarms?page=1&page_size=10`); @@ -1611,8 +1592,28 @@ const labels = []; const values = []; const colors = []; - const colorMap = { sos: '#ef4444', low_battery: '#f97316', remove: '#eab308', fence: '#a855f7', fall: '#3b82f6' }; - const nameMap = { sos: 'SOS', low_battery: '低电量', remove: '拆除', fence: '围栏', fall: '跌倒' }; + const colorMap = { + sos: '#ef4444', power_cut: '#dc2626', vibration: '#f97316', low_battery: '#fb923c', + low_battery_protection: '#fdba74', internal_low_battery: '#fbbf24', + enter_fence: '#a855f7', exit_fence: '#c084fc', displacement: '#8b5cf6', + over_speed: '#f43f5e', remove: '#eab308', cover_open: '#facc15', + power_on: '#22c55e', power_off: '#64748b', shutdown: '#475569', + sim_change: '#06b6d4', airplane_mode: '#0ea5e9', + enter_gps_dead_zone: '#94a3b8', exit_gps_dead_zone: '#cbd5e1', + gps_first_fix: '#34d399', door: '#14b8a6', + voice_alarm: '#e879f9', fake_base_station: '#f472b6', + normal: '#6b7280', acc_on: '#10b981', acc_off: '#9ca3af', + }; + const nameMap = { + normal: '正常', sos: 'SOS', power_cut: '断电', vibration: '振动', + enter_fence: '进围栏', exit_fence: '出围栏', over_speed: '超速', + displacement: '位移', enter_gps_dead_zone: '进GPS盲区', exit_gps_dead_zone: '出GPS盲区', + power_on: '开机', gps_first_fix: 'GPS首次定位', low_battery: '低电量', + low_battery_protection: '低电保护', sim_change: 'SIM更换', power_off: '关机', + airplane_mode: '飞行模式', remove: '拆除', door: '门', shutdown: '低电关机', + voice_alarm: '声控', fake_base_station: '伪基站', cover_open: '开盖', + internal_low_battery: '内部低电', acc_on: 'ACC开', acc_off: 'ACC关', + }; if (byType && typeof byType === 'object') { Object.entries(byType).forEach(([key, val]) => { @@ -2635,7 +2636,7 @@
- + @@ -2667,7 +2668,17 @@ body: JSON.stringify({ acknowledged }), }); showToast(acknowledged ? '告警已确认' : '告警已取消确认'); - loadAlarms(); + // Update row in-place instead of full reload + const cb = document.querySelector(`.alarm-sel-cb[value="${id}"]`); + if (cb) { + const tr = cb.closest('tr'); + const cells = tr.querySelectorAll('td'); + // col 7 = acknowledged badge, col 9 = action button + if (cells[7]) cells[7].innerHTML = acknowledged ? '已确认' : '未确认'; + if (cells[9]) cells[9].innerHTML = acknowledged + ? `` + : ``; + } loadAlarmStats(); } catch (err) { showToast('操作失败: ' + err.message, 'error'); @@ -2724,7 +2735,7 @@ } else { tbody.innerHTML = items.map(a => { const posStr = a.address || (a.latitude != null ? `${Number(a.latitude).toFixed(6)}, ${Number(a.longitude).toFixed(6)}` : '-'); - const gpsIcon = a.gps_positioned ? '' : ''; + const locMethod = a.gps_positioned ? 'GPS' : (a.wifi_data ? 'WiFi' : 'LBS'); const battStr = a.battery_level != null ? `${a.battery_level}%` : '-'; const sigStr = a.gsm_signal != null ? `GSM:${a.gsm_signal}` : ''; const lbsStr = a.mcc != null ? `${a.mcc}/${a.mnc || 0}/${a.lac || 0}/${a.cell_id || 0}` : '-'; @@ -2735,7 +2746,7 @@ - + @@ -3979,7 +3990,7 @@ // ==================== COMMANDS ==================== async function sendCommand() { - const deviceId = document.getElementById('cmdDeviceSelect').value; + const deviceId = document.getElementById('cmdUnifiedDevice').value; const commandType = document.getElementById('cmdType').value.trim(); const commandContent = document.getElementById('cmdContent').value.trim(); @@ -4001,7 +4012,7 @@ } async function sendMessage() { - const deviceId = document.getElementById('msgDeviceSelect').value; + const deviceId = document.getElementById('cmdUnifiedDevice').value; const message = document.getElementById('msgContent').value.trim(); if (!deviceId) { showToast('请选择设备', 'error'); return; } @@ -4034,7 +4045,7 @@ } async function sendTTS() { - const deviceId = document.getElementById('ttsDeviceSelect').value; + const deviceId = document.getElementById('cmdUnifiedDevice').value; const text = document.getElementById('ttsContent').value.trim(); if (!deviceId) { showToast('请选择目标设备', 'error'); return; } diff --git a/app/tcp_server.py b/app/tcp_server.py index 686247c..cee6a02 100644 --- a/app/tcp_server.py +++ b/app/tcp_server.py @@ -2445,6 +2445,13 @@ class TCPManager: return False _reader, writer, conn_info = conn + + # Check if the writer is still alive + if writer.is_closing(): + logger.warning("IMEI=%s writer is closing, removing stale connection", imei) + del self.connections[imei] + return False + serial = conn_info.next_serial() # Build 0x80 online-command packet @@ -2469,7 +2476,8 @@ class TCPManager: command_content, ) except Exception: - logger.exception("Failed to send command to IMEI=%s", imei) + logger.exception("Failed to send command to IMEI=%s, removing stale connection", imei) + self.connections.pop(imei, None) return False return True @@ -2495,6 +2503,12 @@ class TCPManager: return False _reader, writer, conn_info = conn + + if writer.is_closing(): + logger.warning("IMEI=%s writer is closing, removing stale connection", imei) + del self.connections[imei] + return False + serial = conn_info.next_serial() msg_bytes = message.encode("utf-16-be") @@ -2517,7 +2531,8 @@ class TCPManager: logger.info("Message sent to IMEI=%s (%d bytes)", imei, len(msg_bytes)) return True except Exception: - logger.exception("Failed to send message to IMEI=%s", imei) + logger.exception("Failed to send message to IMEI=%s, removing stale connection", imei) + self.connections.pop(imei, None) return False # ------------------------------------------------------------------
ID 类型 设备ID ${escapeHtml(a.device_id || '-')} ${alarmTypeName(a.alarm_type)}${escapeHtml(a.alarm_source || '-')}${({'single_fence':'单围栏','multi_fence':'多围栏','lbs':'基站','wifi':'WiFi'})[a.alarm_source] || escapeHtml(a.alarm_source || '-')} ${a.address ? escapeHtml(a.address) : (a.latitude != null ? Number(a.latitude).toFixed(6) + ', ' + Number(a.longitude).toFixed(6) : '-')} ${a.battery_level != null ? a.battery_level + '%' : '-'} ${a.gsm_signal != null ? a.gsm_signal : '-'}${escapeHtml(a.device_id || '-')} ${attendanceTypeName(a.attendance_type)} ${srcLabel}${gpsIcon} ${escapeHtml(posStr)}${locMethod} ${escapeHtml(posStr)} ${battStr} ${sigStr} ${lbsStr} ${formatTime(a.recorded_at)}