feat: 13个统计/聚合API + 前端同步 + 待完成功能文档

API新增:
- GET /api/system/overview 系统总览(在线率/今日统计/表大小)
- GET /api/locations/stats 位置统计(类型分布/小时趋势)
- GET /api/locations/track-summary/{id} 轨迹摘要(距离/时长/速度)
- POST /api/alarms/batch-acknowledge 批量确认告警
- GET /api/attendance/report 考勤日报表(每设备每天汇总)
- GET /api/bluetooth/stats 蓝牙统计(类型/TOP信标/RSSI分布)
- GET /api/heartbeats/stats 心跳统计(活跃设备/电量/间隔分析)
- GET /api/fences/stats 围栏统计(绑定/进出状态/今日事件)
- GET /api/fences/{id}/events 围栏进出事件历史
- GET /api/commands/stats 指令统计(成功率/类型/趋势)

API增强:
- devices/stats: 新增by_type/battery_distribution/signal_distribution
- alarms/stats: 新增today/by_source/daily_trend/top_devices
- attendance/stats: 新增today/by_source/daily_trend/by_device

前端同步:
- 仪表盘: 今日告警/考勤/定位卡片 + 在线率
- 告警页: 批量确认按钮 + 今日计数
- 考勤页: 今日计数
- 轨迹: 加载后显示距离/时长/速度摘要
- 蓝牙/围栏/指令页: 统计面板

文档: CLAUDE.md待完成功能按优先级重新规划

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

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-31 10:11:33 +00:00
parent b25eafc483
commit 8157f9cb52
10 changed files with 1044 additions and 51 deletions

View File

@@ -270,6 +270,24 @@
<span class="text-xs text-gray-500" id="dashConnectedHint"></span>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-bell text-red-400 mr-1"></i>今日告警</p>
<p class="text-2xl font-bold text-red-400" id="dashTodayAlarms">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-user-check text-cyan-400 mr-1"></i>今日考勤</p>
<p class="text-2xl font-bold text-cyan-400" id="dashTodayAttendance">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-map-pin text-blue-400 mr-1"></i>今日定位</p>
<p class="text-2xl font-bold text-blue-400" id="dashTodayLocations">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-percentage text-green-400 mr-1"></i>在线率</p>
<p class="text-2xl font-bold text-green-400" id="dashOnlineRate">-</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="stat-card">
@@ -522,6 +540,7 @@
<div class="stat-card">
<p class="text-gray-400 text-sm">告警总数</p>
<p class="text-2xl font-bold mt-1" id="alarmStatTotal">-</p>
<p class="text-xs text-gray-500 mt-1" id="alarmStatToday"></p>
</div>
<div class="stat-card">
<p class="text-gray-400 text-sm">未确认</p>
@@ -563,6 +582,7 @@
<button class="btn btn-primary" onclick="loadAlarms()"><i class="fas fa-search"></i> 查询</button>
<button class="btn btn-secondary" onclick="loadAlarms()"><i class="fas fa-sync-alt"></i> 刷新</button>
<button class="btn" style="background:#b91c1c;color:#fff" id="btnBatchDeleteAlarm" onclick="batchDeleteSelectedAlarms()" disabled><i class="fas fa-trash-alt"></i> 删除选中 (<span id="alarmSelCount">0</span>)</button>
<button class="btn btn-success" id="btnBatchAckAlarm" onclick="batchAcknowledgeAlarms()" disabled><i class="fas fa-check-double"></i> 批量确认 (<span id="alarmAckCount">0</span>)</button>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="alarmsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
@@ -570,7 +590,7 @@
<table>
<thead>
<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);updateSelCount('alarm-sel-cb','alarmAckCount','btnBatchAckAlarm')" title="全选当前页"></th>
<th>IMEI</th>
<th>类型</th>
<th>来源</th>
@@ -613,6 +633,7 @@
<div class="stat-card">
<p class="text-gray-400 text-sm">总记录数</p>
<p class="text-2xl font-bold mt-1" id="attStatTotal">-</p>
<p class="text-xs text-gray-500 mt-1" id="attStatToday"></p>
</div>
<div class="stat-card">
<p class="text-gray-400 text-sm">签到次数</p>
@@ -701,6 +722,24 @@
</div>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fab fa-bluetooth-b text-blue-400 mr-1"></i>总记录</p>
<p class="text-2xl font-bold" id="btStatTotal">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-fingerprint text-purple-400 mr-1"></i>打卡</p>
<p class="text-2xl font-bold text-purple-400" id="btStatPunch">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-map-marker-alt text-cyan-400 mr-1"></i>定位</p>
<p class="text-2xl font-bold text-cyan-400" id="btStatLocation">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-broadcast-tower text-green-400 mr-1"></i>信标数</p>
<p class="text-2xl font-bold text-green-400" id="btStatBeacons">-</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 mb-4">
<select id="btDeviceFilter" style="width:180px">
<option value="">全部设备</option>
@@ -849,6 +888,24 @@
</div>
<!-- Tab Content: Fence List + Map -->
<div id="fenceTabContentList" style="display:flex;flex-direction:column;flex:1;overflow:hidden">
<div class="grid grid-cols-4 gap-3 mb-3" id="fenceStatsRow">
<div style="background:#1f2937;border:1px solid #374151;border-radius:8px;padding:10px 14px;text-align:center">
<p class="text-gray-500" style="font-size:11px">总围栏</p>
<p class="text-lg font-bold" id="fenceStatTotal">-</p>
</div>
<div style="background:#1f2937;border:1px solid #374151;border-radius:8px;padding:10px 14px;text-align:center">
<p class="text-gray-500" style="font-size:11px">已启用</p>
<p class="text-lg font-bold text-green-400" id="fenceStatActive">-</p>
</div>
<div style="background:#1f2937;border:1px solid #374151;border-radius:8px;padding:10px 14px;text-align:center">
<p class="text-gray-500" style="font-size:11px">绑定设备</p>
<p class="text-lg font-bold text-cyan-400" id="fenceStatBindings">-</p>
</div>
<div style="background:#1f2937;border:1px solid #374151;border-radius:8px;padding:10px 14px;text-align:center">
<p class="text-gray-500" style="font-size:11px">今日事件</p>
<p class="text-lg font-bold text-yellow-400" id="fenceStatEvents">-</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 mb-3">
<button class="btn btn-primary" onclick="loadFences()"><i class="fas fa-sync-alt"></i> 刷新</button>
<div style="flex:1;display:flex;gap:6px;max-width:400px">
@@ -1030,6 +1087,25 @@
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4" id="cmdStatsRow">
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-terminal text-blue-400 mr-1"></i>指令总数</p>
<p class="text-2xl font-bold" id="cmdStatTotal">-</p>
<p class="text-xs text-gray-500 mt-1" id="cmdStatToday"></p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-check-circle text-green-400 mr-1"></i>成功率</p>
<p class="text-2xl font-bold text-green-400" id="cmdStatRate">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-paper-plane text-cyan-400 mr-1"></i>已发送</p>
<p class="text-2xl font-bold text-cyan-400" id="cmdStatSent">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-times-circle text-red-400 mr-1"></i>失败</p>
<p class="text-2xl font-bold text-red-400" id="cmdStatFailed">-</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 mb-4">
<h3 class="text-lg font-semibold"><i class="fas fa-history mr-2 text-gray-400"></i>指令历史</h3>
<div class="flex-1"></div>
@@ -1314,11 +1390,11 @@
case 'locations': initLocationMap(); loadDeviceSelectors(); break;
case 'alarms': loadAlarmStats(); loadAlarms(); loadDeviceSelectors(); break;
case 'attendance': loadAttendanceStats(); loadAttendance(); loadDeviceSelectors(); break;
case 'bluetooth': loadBluetooth(); loadDeviceSelectors(); break;
case 'bluetooth': loadBluetoothStats(); loadBluetooth(); loadDeviceSelectors(); break;
case 'beacons': loadBeacons(); break;
case 'fences': initFenceMap(); loadFences(); break;
case 'fences': initFenceMap(); loadFenceStats(); loadFences(); break;
case 'datalog': loadDataLogStats(); loadDataLog(); loadDeviceSelectors(); break;
case 'commands': loadCommands(); loadDeviceSelectors(); break;
case 'commands': loadCommandStats(); loadCommands(); loadDeviceSelectors(); break;
}
}
@@ -1589,10 +1665,11 @@
// ==================== DASHBOARD ====================
async function loadDashboard() {
try {
const [deviceStats, alarmStats, health] = await Promise.allSettled([
const [deviceStats, alarmStats, health, overview] = await Promise.allSettled([
apiCall(`${API_BASE}/devices/stats`),
apiCall(`${API_BASE}/alarms/stats`),
apiCall('/health'),
apiCall(`${API_BASE}/system/overview`),
]);
if (deviceStats.status === 'fulfilled') {
@@ -1622,6 +1699,15 @@
document.getElementById('dashSystemStatus').textContent = '无法连接';
}
if (overview.status === 'fulfilled') {
const ov = overview.value;
animateCounter('dashTodayAlarms', ov.today?.alarms || 0);
animateCounter('dashTodayAttendance', ov.today?.attendance || 0);
animateCounter('dashTodayLocations', ov.today?.locations || 0);
const rate = ov.devices?.online_rate;
document.getElementById('dashOnlineRate').textContent = rate != null ? rate + '%' : '-';
}
// 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'});
@@ -3063,6 +3149,17 @@
const legend = document.getElementById('mapLegend');
if (legend) legend.style.display = 'block';
showToast(`已加载 ${total} 个轨迹点${_hideLowPrecision ? ' (已隐藏低精度)' : ''}`);
// Load track summary
try {
const summary = await apiCall(`${API_BASE}/locations/track-summary/${deviceId}?start_time=${startTime}T00:00:00&end_time=${endTime}T23:59:59`);
if (summary && summary.point_count > 0) {
const dist = summary.total_distance_km != null ? summary.total_distance_km.toFixed(2) + 'km' : '-';
const dur = summary.duration_minutes != null ? Math.round(summary.duration_minutes) + '分钟' : '-';
const spd = summary.avg_speed_kmh != null ? summary.avg_speed_kmh.toFixed(1) + 'km/h' : '-';
showToast(`轨迹摘要: ${summary.point_count}点, ${dist}, ${dur}, 均速${spd}`, 'info');
}
} catch(e) { console.warn('Track summary failed:', e); }
} catch (err) {
showToast('加载轨迹失败: ' + err.message, 'error');
}
@@ -3215,6 +3312,8 @@
document.getElementById('alarmStatTotal').textContent = stats.total || 0;
document.getElementById('alarmStatUnack').textContent = stats.unacknowledged || 0;
document.getElementById('alarmStatAck').textContent = stats.acknowledged || 0;
const alarmTodayEl = document.getElementById('alarmStatToday');
if (alarmTodayEl) alarmTodayEl.textContent = `今日 ${stats.today || 0}`;
renderAlarmDoughnut('alarmTypeChart', stats.by_type, true);
} catch (err) {
console.error('Failed to load alarm stats:', err);
@@ -3249,7 +3348,7 @@
} else {
tbody.innerHTML = items.map(a => `
<tr>
<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');updateSelCount('alarm-sel-cb','alarmAckCount','btnBatchAckAlarm')"></td>
<td class="font-mono text-xs">${escapeHtml(_imei(a.device_id))}</td>
<td><span class="${alarmTypeClass(a.alarm_type)} font-semibold">${alarmTypeName(a.alarm_type)}</span></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>
@@ -3301,6 +3400,24 @@
}
}
async function batchAcknowledgeAlarms() {
const cbs = document.querySelectorAll('.alarm-sel-cb:checked');
if (!cbs.length) return;
const ids = [...cbs].map(c => parseInt(c.value));
if (!confirm(`确认批量确认 ${ids.length} 条告警?`)) return;
try {
await apiCall(`${API_BASE}/alarms/batch-acknowledge`, {
method: 'POST',
body: JSON.stringify({ alarm_ids: ids, acknowledged: true }),
});
showToast(`已批量确认 ${ids.length} 条告警`, 'success');
loadAlarms();
loadAlarmStats();
} catch (err) {
showToast('批量确认失败: ' + err.message, 'error');
}
}
// ==================== ATTENDANCE ====================
async function loadAttendanceStats() {
try {
@@ -3318,6 +3435,8 @@
document.getElementById('attStatCheckOut').textContent = stats.by_type?.clock_out || stats.check_out || 0;
const other = stats.total - (stats.by_type?.clock_in || 0) - (stats.by_type?.clock_out || 0);
document.getElementById('attStatOther').textContent = other > 0 ? other : 0;
const attTodayEl = document.getElementById('attStatToday');
if (attTodayEl) attTodayEl.textContent = `今日 ${stats.today || 0}`;
} catch (err) {
console.error('Failed to load attendance stats:', err);
}
@@ -3393,6 +3512,18 @@
}
// ==================== BLUETOOTH ====================
async function loadBluetoothStats() {
try {
const stats = await apiCall(`${API_BASE}/bluetooth/stats`);
document.getElementById('btStatTotal').textContent = stats.total || 0;
document.getElementById('btStatPunch').textContent = stats.by_type?.punch || 0;
document.getElementById('btStatLocation').textContent = stats.by_type?.location || 0;
document.getElementById('btStatBeacons').textContent = stats.top_beacons?.length || 0;
} catch (err) {
console.error('Failed to load bluetooth stats:', err);
}
}
async function loadBluetooth(page) {
if (page) pageState.bluetooth.page = page;
const p = pageState.bluetooth.page;
@@ -3772,6 +3903,18 @@
}
}
async function loadFenceStats() {
try {
const stats = await apiCall(`${API_BASE}/fences/stats`);
document.getElementById('fenceStatTotal').textContent = stats.total || 0;
document.getElementById('fenceStatActive').textContent = stats.active || 0;
document.getElementById('fenceStatBindings').textContent = stats.total_bindings || 0;
document.getElementById('fenceStatEvents').textContent = stats.today_events || 0;
} catch (err) {
console.error('Failed to load fence stats:', err);
}
}
async function loadFences() {
showLoading('fencesLoading');
try {
@@ -4681,6 +4824,19 @@
}
}
async function loadCommandStats() {
try {
const stats = await apiCall(`${API_BASE}/commands/stats`);
document.getElementById('cmdStatTotal').textContent = stats.total || 0;
document.getElementById('cmdStatToday').textContent = `今日 ${stats.today || 0}`;
document.getElementById('cmdStatRate').textContent = (stats.success_rate || 0) + '%';
document.getElementById('cmdStatSent').textContent = (stats.by_status?.sent || 0) + (stats.by_status?.success || 0);
document.getElementById('cmdStatFailed').textContent = stats.by_status?.failed || 0;
} catch (err) {
console.error('Failed to load command stats:', err);
}
}
async function loadCommands(page) {
if (page) pageState.commands.page = page;
const p = pageState.commands.page;