语音播报
-
-
-
-
@@ -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 @@
|
${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 : '-'} |
@@ -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 @@
${escapeHtml(a.device_id || '-')} |
${attendanceTypeName(a.attendance_type)} |
${srcLabel} |
- ${gpsIcon} ${escapeHtml(posStr)} |
+ ${locMethod} ${escapeHtml(posStr)} |
${battStr} ${sigStr} |
${lbsStr} |
${formatTime(a.recorded_at)} |
@@ -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
# ------------------------------------------------------------------