feat: 管理后台UI全面优化 + TCP指令发送修复

- 指令页面:三个设备选择器合并为顶部统一选择器,在线/离线分组显示
- 仪表盘:8卡片压缩为6紧凑卡片,新增刷新按钮和更新时间戳
- 仪表盘:最近告警添加"查看全部"跳转链接
- 考勤页面:定位方式显示GPS/WiFi/LBS文字标签替代纯图标
- 告警页面:alarm_source中文化(单围栏/多围栏/基站/WiFi)
- 告警页面:确认操作改为DOM单行更新,无需刷新整个表格
- 数据日志:默认选中"位置"类型
- 全部表格:全选复选框tooltip改为"全选当前页"
- TCP修复:移除发送前硬性在线检查,改为尝试发送+失败提示
- TCP修复:检测僵死连接(writer.is_closing)并自动清理

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-31 05:01:04 +00:00
parent 61c300bad8
commit b970b78136
3 changed files with 162 additions and 133 deletions

View File

@@ -103,12 +103,6 @@ async def _send_to_device(
executor : async callable executor : async callable
The actual send function, e.g. tcp_command_service.send_command(...) 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( command_log = await command_service.create_command(
db, db,
device_id=device.id, device_id=device.id,
@@ -117,7 +111,7 @@ async def _send_to_device(
) )
try: try:
await executor() result = await executor()
except Exception as e: except Exception as e:
logging.getLogger(__name__).error("Command send failed: %s", e) logging.getLogger(__name__).error("Command send failed: %s", e)
command_log.status = "failed" command_log.status = "failed"
@@ -128,6 +122,15 @@ async def _send_to_device(
detail=fail_msg, 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.status = "sent"
command_log.sent_at = now_cst() command_log.sent_at = now_cst()
await db.flush() await db.flush()

View File

@@ -226,97 +226,37 @@
<div class="guide-tips"><p><i class="fas fa-sync-alt"></i> 数据每 30 秒自动刷新,无需手动操作</p></div> <div class="guide-tips"><p><i class="fas fa-sync-alt"></i> 数据每 30 秒自动刷新,无需手动操作</p></div>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6"> <div class="flex items-center justify-between mb-4">
<div class="stat-card"> <div class="flex items-center gap-3">
<div class="flex items-center justify-between"> <span class="text-xs text-gray-500" id="dashLastUpdated"></span>
<div>
<p class="text-gray-400 text-sm">设备总数</p>
<p class="text-3xl font-bold mt-1" id="dashTotalDevices">-</p>
</div>
<div class="w-12 h-12 bg-blue-900/50 rounded-xl flex items-center justify-center">
<i class="fas fa-microchip text-blue-400 text-xl"></i>
</div>
</div>
</div>
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">在线设备</p>
<p class="text-3xl font-bold mt-1 text-green-400" id="dashOnlineDevices">-</p>
</div>
<div class="w-12 h-12 bg-green-900/50 rounded-xl flex items-center justify-center">
<i class="fas fa-wifi text-green-400 text-xl"></i>
</div>
</div>
</div>
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">离线设备</p>
<p class="text-3xl font-bold mt-1 text-red-400" id="dashOfflineDevices">-</p>
</div>
<div class="w-12 h-12 bg-red-900/50 rounded-xl flex items-center justify-center">
<i class="fas fa-plug text-red-400 text-xl"></i>
</div>
</div>
</div>
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm">已连接设备</p>
<p class="text-3xl font-bold mt-1 text-purple-400" id="dashConnectedDevices">-</p>
</div>
<div class="w-12 h-12 bg-purple-900/50 rounded-xl flex items-center justify-center">
<i class="fas fa-link text-purple-400 text-xl"></i>
</div>
</div>
</div> </div>
<button class="btn btn-sm" style="padding:4px 12px;font-size:12px;" onclick="loadDashboard()"><i class="fas fa-sync-alt mr-1"></i>刷新</button>
</div> </div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6"> <div class="stat-card" style="padding:16px;">
<div class="stat-card"> <p class="text-gray-400 text-xs mb-1"><i class="fas fa-microchip text-blue-400 mr-1"></i>设备总数</p>
<div class="flex items-center justify-between"> <p class="text-2xl font-bold" id="dashTotalDevices">-</p>
<div>
<p class="text-gray-400 text-sm">告警总数</p>
<p class="text-3xl font-bold mt-1 text-yellow-400" id="dashTotalAlarms">-</p>
</div>
<div class="w-12 h-12 bg-yellow-900/50 rounded-xl flex items-center justify-center">
<i class="fas fa-bell text-yellow-400 text-xl"></i>
</div>
</div>
</div> </div>
<div class="stat-card"> <div class="stat-card" style="padding:16px;">
<div class="flex items-center justify-between"> <p class="text-gray-400 text-xs mb-1"><i class="fas fa-wifi text-green-400 mr-1"></i>在线</p>
<div> <p class="text-2xl font-bold text-green-400" id="dashOnlineDevices">-</p>
<p class="text-gray-400 text-sm">未确认告警</p>
<p class="text-3xl font-bold mt-1 text-orange-400" id="dashUnackAlarms">-</p>
</div>
<div class="w-12 h-12 bg-orange-900/50 rounded-xl flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-orange-400 text-xl"></i>
</div>
</div>
</div> </div>
<div class="stat-card"> <div class="stat-card" style="padding:16px;">
<div class="flex items-center justify-between"> <p class="text-gray-400 text-xs mb-1"><i class="fas fa-plug text-red-400 mr-1"></i>离线</p>
<div> <p class="text-2xl font-bold text-red-400" id="dashOfflineDevices">-</p>
<p class="text-gray-400 text-sm">已确认告警</p>
<p class="text-3xl font-bold mt-1 text-teal-400" id="dashAckAlarms">-</p>
</div>
<div class="w-12 h-12 bg-teal-900/50 rounded-xl flex items-center justify-center">
<i class="fas fa-check-circle text-teal-400 text-xl"></i>
</div>
</div>
</div> </div>
<div class="stat-card"> <div class="stat-card" style="padding:16px;">
<div class="flex items-center justify-between"> <p class="text-gray-400 text-xs mb-1"><i class="fas fa-bell text-yellow-400 mr-1"></i>告警总数</p>
<div> <p class="text-2xl font-bold text-yellow-400" id="dashTotalAlarms">-</p>
<p class="text-gray-400 text-sm">系统状态</p> </div>
<p class="text-lg font-bold mt-1 text-green-400" id="dashSystemStatus">-</p> <div class="stat-card" style="padding:16px;">
</div> <p class="text-gray-400 text-xs mb-1"><i class="fas fa-exclamation-triangle text-orange-400 mr-1"></i>未确认</p>
<div class="w-12 h-12 bg-green-900/50 rounded-xl flex items-center justify-center"> <p class="text-2xl font-bold text-orange-400" id="dashUnackAlarms">-</p>
<i class="fas fa-heartbeat text-green-400 text-xl"></i> </div>
</div> <div class="stat-card" style="padding:16px;">
</div> <p class="text-gray-400 text-xs mb-1"><i class="fas fa-heartbeat text-green-400 mr-1"></i>系统</p>
<p class="text-lg font-bold text-green-400 mt-1" id="dashSystemStatus">-</p>
<span class="text-xs text-gray-500" id="dashConnectedHint"></span>
</div> </div>
</div> </div>
@@ -328,7 +268,10 @@
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-list mr-2 text-red-400"></i>最近告警</h3> <div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold"><i class="fas fa-list mr-2 text-red-400"></i>最近告警</h3>
<a href="#" onclick="event.preventDefault();switchPage('alarms')" class="text-xs text-blue-400 hover:text-blue-300">查看全部 <i class="fas fa-arrow-right ml-1"></i></a>
</div>
<div id="dashRecentAlarms" class="space-y-2" style="max-height: 300px; overflow-y: auto;"> <div id="dashRecentAlarms" class="space-y-2" style="max-height: 300px; overflow-y: auto;">
<p class="text-gray-500 text-sm">加载中...</p> <p class="text-gray-500 text-sm">加载中...</p>
</div> </div>
@@ -468,7 +411,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th style="width:32px"><input type="checkbox" id="locSelectAll" onchange="toggleAllLocCheckboxes(this.checked)" title="全选"></th> <th style="width:32px"><input type="checkbox" id="locSelectAll" onchange="toggleAllLocCheckboxes(this.checked)" title="全选当前页"></th>
<th>设备ID</th> <th>设备ID</th>
<th>类型</th> <th>类型</th>
<th>纬度</th> <th>纬度</th>
@@ -561,7 +504,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th style="width:32px"><input type="checkbox" id="alarmSelectAll" onchange="toggleAllCheckboxes('alarm-sel-cb','alarmSelCount','btnBatchDeleteAlarm',this.checked)" title="全选"></th> <th style="width:32px"><input type="checkbox" id="alarmSelectAll" onchange="toggleAllCheckboxes('alarm-sel-cb','alarmSelCount','btnBatchDeleteAlarm',this.checked)" title="全选当前页"></th>
<th>设备ID</th> <th>设备ID</th>
<th>类型</th> <th>类型</th>
<th>来源</th> <th>来源</th>
@@ -645,7 +588,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th style="width:32px"><input type="checkbox" id="attSelectAll" onchange="toggleAllCheckboxes('att-sel-cb','attSelCount','btnBatchDeleteAtt',this.checked)" title="全选"></th> <th style="width:32px"><input type="checkbox" id="attSelectAll" onchange="toggleAllCheckboxes('att-sel-cb','attSelCount','btnBatchDeleteAtt',this.checked)" title="全选当前页"></th>
<th>设备ID</th> <th>设备ID</th>
<th>类型</th> <th>类型</th>
<th>来源</th> <th>来源</th>
@@ -713,7 +656,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th style="width:32px"><input type="checkbox" id="btSelectAll" onchange="toggleAllCheckboxes('bt-sel-cb','btSelCount','btnBatchDeleteBt',this.checked)" title="全选"></th> <th style="width:32px"><input type="checkbox" id="btSelectAll" onchange="toggleAllCheckboxes('bt-sel-cb','btSelCount','btnBatchDeleteBt',this.checked)" title="全选当前页"></th>
<th>设备ID</th> <th>设备ID</th>
<th>类型</th> <th>类型</th>
<th>信标MAC</th> <th>信标MAC</th>
@@ -902,7 +845,7 @@
<select id="logDeviceFilter" style="width:160px"><option value="">全部设备</option></select> <select id="logDeviceFilter" style="width:160px"><option value="">全部设备</option></select>
<select id="logTypeFilter" style="width:140px"> <select id="logTypeFilter" style="width:140px">
<option value="">全部类型</option> <option value="">全部类型</option>
<option value="location">位置</option> <option value="location" selected>位置</option>
<option value="alarm">告警</option> <option value="alarm">告警</option>
<option value="heartbeat">心跳</option> <option value="heartbeat">心跳</option>
<option value="attendance">考勤</option> <option value="attendance">考勤</option>
@@ -942,7 +885,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th style="width:32px"><input type="checkbox" id="logSelectAll" onchange="toggleAllCheckboxes('log-sel-cb','logSelCount','btnBatchDeleteLog',this.checked)" title="全选"></th> <th style="width:32px"><input type="checkbox" id="logSelectAll" onchange="toggleAllCheckboxes('log-sel-cb','logSelCount','btnBatchDeleteLog',this.checked)" title="全选当前页"></th>
<th>ID</th> <th>ID</th>
<th>类型</th> <th>类型</th>
<th>设备ID</th> <th>设备ID</th>
@@ -980,15 +923,16 @@
<div class="guide-tips"><p><i class="fas fa-exclamation-triangle"></i> 所有下发功能仅对当前在线TCP 已连接)设备有效,离线设备无法接收</p></div> <div class="guide-tips"><p><i class="fas fa-exclamation-triangle"></i> 所有下发功能仅对当前在线TCP 已连接)设备有效,离线设备无法接收</p></div>
</div> </div>
</div> </div>
<div class="stat-card mb-4" style="display:flex;align-items:center;gap:16px;padding:12px 20px;">
<label style="white-space:nowrap;font-weight:600;"><i class="fas fa-broadcast-tower mr-2 text-cyan-400"></i>目标设备</label>
<select id="cmdUnifiedDevice" style="flex:1;max-width:360px;">
<option value="">选择设备...</option>
</select>
<span id="cmdDeviceStatus" class="text-xs text-gray-500"></span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<div class="stat-card"> <div class="stat-card">
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-paper-plane mr-2 text-blue-400"></i>发送指令</h3> <h3 class="text-lg font-semibold mb-4"><i class="fas fa-paper-plane mr-2 text-blue-400"></i>发送指令</h3>
<div class="form-group">
<label>目标设备</label>
<select id="cmdDeviceSelect">
<option value="">选择设备...</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label>指令类型</label> <label>指令类型</label>
<input type="text" id="cmdType" placeholder="如: LOCATE, POWEROFF, RESTART..."> <input type="text" id="cmdType" placeholder="如: LOCATE, POWEROFF, RESTART...">
@@ -1001,12 +945,6 @@
</div> </div>
<div class="stat-card"> <div class="stat-card">
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-comment mr-2 text-green-400"></i>发送消息</h3> <h3 class="text-lg font-semibold mb-4"><i class="fas fa-comment mr-2 text-green-400"></i>发送消息</h3>
<div class="form-group">
<label>目标设备</label>
<select id="msgDeviceSelect">
<option value="">选择设备...</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label>消息内容</label> <label>消息内容</label>
<textarea id="msgContent" rows="5" placeholder="输入要发送给设备的消息..."></textarea> <textarea id="msgContent" rows="5" placeholder="输入要发送给设备的消息..."></textarea>
@@ -1015,12 +953,6 @@
</div> </div>
<div class="stat-card"> <div class="stat-card">
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-volume-up mr-2 text-purple-400"></i>语音播报</h3> <h3 class="text-lg font-semibold mb-4"><i class="fas fa-volume-up mr-2 text-purple-400"></i>语音播报</h3>
<div class="form-group">
<label>目标设备</label>
<select id="ttsDeviceSelect">
<option value="">选择设备...</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label>播报文本</label> <label>播报文本</label>
<textarea id="ttsContent" rows="3" placeholder="输入语音播报内容,设备将通过 TTS 引擎朗读..."></textarea> <textarea id="ttsContent" rows="3" placeholder="输入语音播报内容,设备将通过 TTS 引擎朗读..."></textarea>
@@ -1204,7 +1136,7 @@
low_battery: '低电量', low_battery_protection: '低电保护', low_battery: '低电量', low_battery_protection: '低电保护',
sim_change: 'SIM卡更换', power_off: '关机', sim_change: 'SIM卡更换', power_off: '关机',
airplane_mode: '飞行模式', remove: '拆除', airplane_mode: '飞行模式', remove: '拆除',
door: '门', shutdown: '关机', door: '门', shutdown: '低电关机',
voice_alarm: '声控报警', fake_base_station: '伪基站', voice_alarm: '声控报警', fake_base_station: '伪基站',
cover_open: '开盖', internal_low_battery: '内部低电', cover_open: '开盖', internal_low_battery: '内部低电',
acc_on: 'ACC开', acc_off: 'ACC关', acc_on: 'ACC开', acc_off: 'ACC关',
@@ -1506,7 +1438,7 @@
const data = await apiCall(`${API_BASE}/devices?page=1&page_size=100`); const data = await apiCall(`${API_BASE}/devices?page=1&page_size=100`);
const devices = data.items || []; const devices = data.items || [];
cachedDevices = devices; cachedDevices = devices;
const selectors = ['locDeviceSelect', 'alarmDeviceFilter', 'attDeviceFilter', 'btDeviceFilter', 'cmdDeviceSelect', 'msgDeviceSelect', 'ttsDeviceSelect', 'cmdHistoryDeviceFilter']; const selectors = ['locDeviceSelect', 'alarmDeviceFilter', 'attDeviceFilter', 'btDeviceFilter', 'cmdHistoryDeviceFilter'];
selectors.forEach(id => { selectors.forEach(id => {
const sel = document.getElementById(id); const sel = document.getElementById(id);
if (!sel) return; if (!sel) return;
@@ -1521,6 +1453,51 @@
}); });
if (currentVal) sel.value = currentVal; 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 = '<option value="">选择设备...</option>';
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 = '<span style="color:#22c55e"><i class="fas fa-circle" style="font-size:8px;vertical-align:middle;margin-right:4px"></i>在线 - 可下发</span>';
} else {
st.innerHTML = '<span style="color:#ef4444"><i class="fas fa-circle" style="font-size:8px;vertical-align:middle;margin-right:4px"></i>离线 - 无法接收指令</span>';
}
};
}
// Render device panel on locations page // Render device panel on locations page
if (currentPage === 'locations' && document.getElementById('locPanelList')) { if (currentPage === 'locations' && document.getElementById('locPanelList')) {
const sorted = sortDevicesByActivity(devices); const sorted = sortDevicesByActivity(devices);
@@ -1556,7 +1533,6 @@
const as = alarmStats.value; const as = alarmStats.value;
animateCounter('dashTotalAlarms', as.total || 0); animateCounter('dashTotalAlarms', as.total || 0);
animateCounter('dashUnackAlarms', as.unacknowledged || 0); animateCounter('dashUnackAlarms', as.unacknowledged || 0);
animateCounter('dashAckAlarms', as.acknowledged || 0);
renderAlarmDoughnut('dashAlarmChart', as.by_type, false); renderAlarmDoughnut('dashAlarmChart', as.by_type, false);
} }
@@ -1564,14 +1540,19 @@
const h = health.value; const h = health.value;
const status = h.status || 'unknown'; const status = h.status || 'unknown';
const connected = h.connected_devices || 0; const connected = h.connected_devices || 0;
animateCounter('dashConnectedDevices', connected);
document.getElementById('dashSystemStatus').textContent = status === 'ok' || status === 'healthy' ? '正常运行' : status; document.getElementById('dashSystemStatus').textContent = status === 'ok' || status === 'healthy' ? '正常运行' : status;
const hint = document.getElementById('dashConnectedHint');
if (hint) hint.textContent = `TCP ${connected}`;
document.getElementById('healthStatus').innerHTML = `<i class="fas fa-circle text-green-500 mr-1 text-xs"></i>在线 (${connected}台)`; document.getElementById('healthStatus').innerHTML = `<i class="fas fa-circle text-green-500 mr-1 text-xs"></i>在线 (${connected}台)`;
} else { } else {
document.getElementById('healthStatus').innerHTML = `<i class="fas fa-circle text-red-500 mr-1 text-xs"></i>离线`; document.getElementById('healthStatus').innerHTML = `<i class="fas fa-circle text-red-500 mr-1 text-xs"></i>离线`;
document.getElementById('dashSystemStatus').textContent = '无法连接'; 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 // Load recent alarms
try { try {
const alarms = await apiCall(`${API_BASE}/alarms?page=1&page_size=10`); const alarms = await apiCall(`${API_BASE}/alarms?page=1&page_size=10`);
@@ -1611,8 +1592,28 @@
const labels = []; const labels = [];
const values = []; const values = [];
const colors = []; const colors = [];
const colorMap = { sos: '#ef4444', low_battery: '#f97316', remove: '#eab308', fence: '#a855f7', fall: '#3b82f6' }; const colorMap = {
const nameMap = { sos: 'SOS', low_battery: '低电量', remove: '拆除', fence: '围栏', fall: '跌倒' }; 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') { if (byType && typeof byType === 'object') {
Object.entries(byType).forEach(([key, val]) => { Object.entries(byType).forEach(([key, val]) => {
@@ -2635,7 +2636,7 @@
<td onclick="event.stopPropagation()"><input type="checkbox" class="alarm-sel-cb" value="${a.id}" onchange="updateSelCount('alarm-sel-cb','alarmSelCount','btnBatchDeleteAlarm')"></td> <td onclick="event.stopPropagation()"><input type="checkbox" class="alarm-sel-cb" value="${a.id}" onchange="updateSelCount('alarm-sel-cb','alarmSelCount','btnBatchDeleteAlarm')"></td>
<td class="font-mono text-xs">${escapeHtml(a.device_id || '-')}</td> <td class="font-mono text-xs">${escapeHtml(a.device_id || '-')}</td>
<td><span class="${alarmTypeClass(a.alarm_type)} font-semibold">${alarmTypeName(a.alarm_type)}</span></td> <td><span class="${alarmTypeClass(a.alarm_type)} font-semibold">${alarmTypeName(a.alarm_type)}</span></td>
<td>${escapeHtml(a.alarm_source || '-')}</td> <td>${({'single_fence':'<span style="color:#a855f7"><i class="fas fa-draw-polygon mr-1"></i>单围栏</span>','multi_fence':'<span style="color:#c084fc"><i class="fas fa-layer-group mr-1"></i>多围栏</span>','lbs':'<span style="color:#f97316"><i class="fas fa-broadcast-tower mr-1"></i>基站</span>','wifi':'<span style="color:#f59e0b"><i class="fas fa-wifi mr-1"></i>WiFi</span>'})[a.alarm_source] || escapeHtml(a.alarm_source || '-')}</td>
<td class="text-xs">${a.address ? escapeHtml(a.address) : (a.latitude != null ? Number(a.latitude).toFixed(6) + ', ' + Number(a.longitude).toFixed(6) : '-')}</td> <td class="text-xs">${a.address ? escapeHtml(a.address) : (a.latitude != null ? Number(a.latitude).toFixed(6) + ', ' + Number(a.longitude).toFixed(6) : '-')}</td>
<td>${a.battery_level != null ? a.battery_level + '%' : '-'}</td> <td>${a.battery_level != null ? a.battery_level + '%' : '-'}</td>
<td>${a.gsm_signal != null ? a.gsm_signal : '-'}</td> <td>${a.gsm_signal != null ? a.gsm_signal : '-'}</td>
@@ -2667,7 +2668,17 @@
body: JSON.stringify({ acknowledged }), body: JSON.stringify({ acknowledged }),
}); });
showToast(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 ? '<span class="badge badge-online">已确认</span>' : '<span class="badge badge-offline">未确认</span>';
if (cells[9]) cells[9].innerHTML = acknowledged
? `<button class="btn btn-warning btn-sm" onclick="toggleAlarmAck('${id}', false)"><i class="fas fa-undo"></i></button>`
: `<button class="btn btn-success btn-sm" onclick="toggleAlarmAck('${id}', true)"><i class="fas fa-check"></i></button>`;
}
loadAlarmStats(); loadAlarmStats();
} catch (err) { } catch (err) {
showToast('操作失败: ' + err.message, 'error'); showToast('操作失败: ' + err.message, 'error');
@@ -2724,7 +2735,7 @@
} else { } else {
tbody.innerHTML = items.map(a => { tbody.innerHTML = items.map(a => {
const posStr = a.address || (a.latitude != null ? `${Number(a.latitude).toFixed(6)}, ${Number(a.longitude).toFixed(6)}` : '-'); const posStr = a.address || (a.latitude != null ? `${Number(a.latitude).toFixed(6)}, ${Number(a.longitude).toFixed(6)}` : '-');
const gpsIcon = a.gps_positioned ? '<i class="fas fa-satellite text-green-400" title="GPS定位"></i>' : '<i class="fas fa-broadcast-tower text-yellow-400" title="基站/WiFi定位"></i>'; const locMethod = a.gps_positioned ? '<span style="color:#22c55e;font-size:11px"><i class="fas fa-satellite mr-1"></i>GPS</span>' : (a.wifi_data ? '<span style="color:#f59e0b;font-size:11px"><i class="fas fa-wifi mr-1"></i>WiFi</span>' : '<span style="color:#f97316;font-size:11px"><i class="fas fa-broadcast-tower mr-1"></i>LBS</span>');
const battStr = a.battery_level != null ? `${a.battery_level}%` : '-'; const battStr = a.battery_level != null ? `${a.battery_level}%` : '-';
const sigStr = a.gsm_signal != null ? `GSM:${a.gsm_signal}` : ''; 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}` : '-'; const lbsStr = a.mcc != null ? `${a.mcc}/${a.mnc || 0}/${a.lac || 0}/${a.cell_id || 0}` : '-';
@@ -2735,7 +2746,7 @@
<td class="font-mono text-xs">${escapeHtml(a.device_id || '-')}</td> <td class="font-mono text-xs">${escapeHtml(a.device_id || '-')}</td>
<td><span class="${a.attendance_type === 'clock_in' ? 'text-green-400' : 'text-blue-400'} font-semibold">${attendanceTypeName(a.attendance_type)}</span></td> <td><span class="${a.attendance_type === 'clock_in' ? 'text-green-400' : 'text-blue-400'} font-semibold">${attendanceTypeName(a.attendance_type)}</span></td>
<td style="color:${srcColor};font-size:12px">${srcLabel}</td> <td style="color:${srcColor};font-size:12px">${srcLabel}</td>
<td class="text-xs">${gpsIcon} ${escapeHtml(posStr)}</td> <td class="text-xs">${locMethod} ${escapeHtml(posStr)}</td>
<td class="text-xs">${battStr} ${sigStr}</td> <td class="text-xs">${battStr} ${sigStr}</td>
<td class="text-xs font-mono">${lbsStr}</td> <td class="text-xs font-mono">${lbsStr}</td>
<td class="text-xs text-gray-400">${formatTime(a.recorded_at)}</td> <td class="text-xs text-gray-400">${formatTime(a.recorded_at)}</td>
@@ -3979,7 +3990,7 @@
// ==================== COMMANDS ==================== // ==================== COMMANDS ====================
async function sendCommand() { async function sendCommand() {
const deviceId = document.getElementById('cmdDeviceSelect').value; const deviceId = document.getElementById('cmdUnifiedDevice').value;
const commandType = document.getElementById('cmdType').value.trim(); const commandType = document.getElementById('cmdType').value.trim();
const commandContent = document.getElementById('cmdContent').value.trim(); const commandContent = document.getElementById('cmdContent').value.trim();
@@ -4001,7 +4012,7 @@
} }
async function sendMessage() { async function sendMessage() {
const deviceId = document.getElementById('msgDeviceSelect').value; const deviceId = document.getElementById('cmdUnifiedDevice').value;
const message = document.getElementById('msgContent').value.trim(); const message = document.getElementById('msgContent').value.trim();
if (!deviceId) { showToast('请选择设备', 'error'); return; } if (!deviceId) { showToast('请选择设备', 'error'); return; }
@@ -4034,7 +4045,7 @@
} }
async function sendTTS() { async function sendTTS() {
const deviceId = document.getElementById('ttsDeviceSelect').value; const deviceId = document.getElementById('cmdUnifiedDevice').value;
const text = document.getElementById('ttsContent').value.trim(); const text = document.getElementById('ttsContent').value.trim();
if (!deviceId) { showToast('请选择目标设备', 'error'); return; } if (!deviceId) { showToast('请选择目标设备', 'error'); return; }

View File

@@ -2445,6 +2445,13 @@ class TCPManager:
return False return False
_reader, writer, conn_info = conn _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() serial = conn_info.next_serial()
# Build 0x80 online-command packet # Build 0x80 online-command packet
@@ -2469,7 +2476,8 @@ class TCPManager:
command_content, command_content,
) )
except Exception: 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 False
return True return True
@@ -2495,6 +2503,12 @@ class TCPManager:
return False return False
_reader, writer, conn_info = conn _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() serial = conn_info.next_serial()
msg_bytes = message.encode("utf-16-be") 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)) logger.info("Message sent to IMEI=%s (%d bytes)", imei, len(msg_bytes))
return True return True
except Exception: 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 return False
# ------------------------------------------------------------------ # ------------------------------------------------------------------