From 61c300bad8e5d748f58bdc90dbc9696e80b0361c Mon Sep 17 00:00:00 2001 From: default Date: Tue, 31 Mar 2026 02:03:21 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=91=8A=E8=AD=A6/=E8=80=83=E5=8B=A4/?= =?UTF-8?q?=E8=93=9D=E7=89=99/=E6=95=B0=E6=8D=AE=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E6=B7=BB=E5=8A=A0=E6=89=B9=E9=87=8F=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 POST /api/alarms/batch-delete、/api/bluetooth/batch-delete、 /api/heartbeats/batch-delete 批量删除端点 (最多500条) - 四个页面表格添加全选复选框和"删除选中"按钮 - 提取通用 toggleAllCheckboxes/updateSelCount/_batchDelete 函数 - 数据日志页面根据当前查询类型自动路由到对应的批量删除API via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- app/routers/alarms.py | 26 +++++++++++++ app/routers/bluetooth.py | 25 ++++++++++++ app/routers/heartbeats.py | 25 ++++++++++++ app/static/admin.html | 82 ++++++++++++++++++++++++++------------- 4 files changed, 131 insertions(+), 27 deletions(-) diff --git a/app/routers/alarms.py b/app/routers/alarms.py index bbbc921..28a094a 100644 --- a/app/routers/alarms.py +++ b/app/routers/alarms.py @@ -131,6 +131,32 @@ async def alarm_stats(db: AsyncSession = Depends(get_db)): ) +@router.post( + "/batch-delete", + response_model=APIResponse[dict], + summary="批量删除告警记录 / Batch delete alarms", + dependencies=[Depends(require_write)], +) +async def batch_delete_alarms( + body: dict, + db: AsyncSession = Depends(get_db), +): + """批量删除告警记录,最多500条。 / Batch delete alarm records (max 500).""" + alarm_ids = body.get("alarm_ids", []) + if not alarm_ids: + raise HTTPException(status_code=400, detail="alarm_ids is required") + if len(alarm_ids) > 500: + raise HTTPException(status_code=400, detail="Max 500 records per request") + result = await db.execute( + select(AlarmRecord).where(AlarmRecord.id.in_(alarm_ids)) + ) + records = list(result.scalars().all()) + for r in records: + await db.delete(r) + await db.flush() + return APIResponse(data={"deleted": len(records)}) + + @router.get( "/{alarm_id}", response_model=APIResponse[AlarmRecordResponse], diff --git a/app/routers/bluetooth.py b/app/routers/bluetooth.py index 62d5430..3b38ae6 100644 --- a/app/routers/bluetooth.py +++ b/app/routers/bluetooth.py @@ -142,6 +142,31 @@ async def device_bluetooth_records( ) +@router.post( + "/batch-delete", + response_model=APIResponse[dict], + summary="批量删除蓝牙记录 / Batch delete bluetooth records", +) +async def batch_delete_bluetooth( + body: dict, + db: AsyncSession = Depends(get_db), +): + """批量删除蓝牙记录,最多500条。 / Batch delete bluetooth records (max 500).""" + record_ids = body.get("record_ids", []) + if not record_ids: + raise HTTPException(status_code=400, detail="record_ids is required") + if len(record_ids) > 500: + raise HTTPException(status_code=400, detail="Max 500 records per request") + result = await db.execute( + select(BluetoothRecord).where(BluetoothRecord.id.in_(record_ids)) + ) + records = list(result.scalars().all()) + for r in records: + await db.delete(r) + await db.flush() + return APIResponse(data={"deleted": len(records)}) + + # NOTE: /{record_id} must be after /device/{device_id} to avoid route conflicts @router.get( "/{record_id}", diff --git a/app/routers/heartbeats.py b/app/routers/heartbeats.py index 43a0429..35bacaa 100644 --- a/app/routers/heartbeats.py +++ b/app/routers/heartbeats.py @@ -73,6 +73,31 @@ async def list_heartbeats( ) +@router.post( + "/batch-delete", + response_model=APIResponse[dict], + summary="批量删除心跳记录 / Batch delete heartbeats", +) +async def batch_delete_heartbeats( + body: dict, + db: AsyncSession = Depends(get_db), +): + """批量删除心跳记录,最多500条。 / Batch delete heartbeat records (max 500).""" + record_ids = body.get("record_ids", []) + if not record_ids: + raise HTTPException(status_code=400, detail="record_ids is required") + if len(record_ids) > 500: + raise HTTPException(status_code=400, detail="Max 500 records per request") + result = await db.execute( + select(HeartbeatRecord).where(HeartbeatRecord.id.in_(record_ids)) + ) + records = list(result.scalars().all()) + for r in records: + await db.delete(r) + await db.flush() + return APIResponse(data={"deleted": len(records)}) + + @router.get( "/{heartbeat_id}", response_model=APIResponse[HeartbeatRecordResponse], diff --git a/app/static/admin.html b/app/static/admin.html index e4b05a9..4bad531 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -553,6 +553,7 @@ +
@@ -560,6 +561,7 @@ + @@ -572,7 +574,7 @@ - +
设备ID 类型 来源
加载中...
加载中...
@@ -635,6 +637,7 @@ +
@@ -642,6 +645,7 @@ + @@ -653,7 +657,7 @@ - +
设备ID 类型 来源
加载中...
加载中...
@@ -701,6 +705,7 @@ +
@@ -708,6 +713,7 @@ + @@ -719,7 +725,7 @@ - +
设备ID 类型 信标MAC
加载中...
加载中...
@@ -906,6 +912,7 @@ +
@@ -935,6 +942,7 @@ + @@ -946,7 +954,7 @@ - +
ID 类型 设备ID
选择筛选条件后点击查询
选择筛选条件后点击查询
@@ -1902,32 +1910,44 @@ } } - function toggleAllLocCheckboxes(checked) { - document.querySelectorAll('.loc-sel-cb').forEach(cb => { cb.checked = checked; }); - updateLocSelCount(); + // ==================== GENERIC BATCH DELETE HELPERS ==================== + function toggleAllCheckboxes(cbClass, countSpanId, btnId, checked) { + document.querySelectorAll('.' + cbClass).forEach(cb => { cb.checked = checked; }); + updateSelCount(cbClass, countSpanId, btnId); } - - function updateLocSelCount() { - const count = document.querySelectorAll('.loc-sel-cb:checked').length; - document.getElementById('locSelCount').textContent = count; - document.getElementById('btnBatchDeleteLoc').disabled = count === 0; + function updateSelCount(cbClass, countSpanId, btnId) { + const count = document.querySelectorAll('.' + cbClass + ':checked').length; + document.getElementById(countSpanId).textContent = count; + document.getElementById(btnId).disabled = count === 0; } + // Location (compat wrappers) + function toggleAllLocCheckboxes(checked) { toggleAllCheckboxes('loc-sel-cb','locSelCount','btnBatchDeleteLoc', checked); } + function updateLocSelCount() { updateSelCount('loc-sel-cb','locSelCount','btnBatchDeleteLoc'); } - async function batchDeleteSelectedLocations() { - const ids = Array.from(document.querySelectorAll('.loc-sel-cb:checked')).map(cb => parseInt(cb.value)); + async function _batchDelete(cbClass, apiPath, idKey, label, reloadFn) { + const ids = Array.from(document.querySelectorAll('.' + cbClass + ':checked')).map(cb => parseInt(cb.value)); if (ids.length === 0) { showToast('请先勾选要删除的记录', 'info'); return; } - if (!confirm(`确定批量删除选中的 ${ids.length} 条位置记录?`)) return; + if (!confirm(`确定批量删除选中的 ${ids.length} 条${label}?`)) return; try { - const result = await apiCall(`${API_BASE}/locations/batch-delete`, { - method: 'POST', - body: JSON.stringify({ location_ids: ids }), + const result = await apiCall(`${API_BASE}/${apiPath}`, { + method: 'POST', body: JSON.stringify({ [idKey]: ids }), }); showToast(`已删除 ${result.deleted} 条记录`); - loadLocationRecords(); + reloadFn(); } catch (err) { showToast('批量删除失败: ' + err.message, 'error'); } } + function batchDeleteSelectedLocations() { _batchDelete('loc-sel-cb', 'locations/batch-delete', 'location_ids', '位置记录', loadLocationRecords); } + function batchDeleteSelectedAlarms() { _batchDelete('alarm-sel-cb', 'alarms/batch-delete', 'alarm_ids', '告警记录', loadAlarms); } + function batchDeleteSelectedAttendance() { _batchDelete('att-sel-cb', 'attendance/batch-delete', 'attendance_ids', '考勤记录', loadAttendance); } + function batchDeleteSelectedBluetooth() { _batchDelete('bt-sel-cb', 'bluetooth/batch-delete', 'record_ids', '蓝牙记录', loadBluetooth); } + function batchDeleteSelectedDatalog() { + const logType = document.getElementById('logTypeFilter').value || 'location'; + const apiMap = { location: ['locations/batch-delete','location_ids'], alarm: ['alarms/batch-delete','alarm_ids'], attendance: ['attendance/batch-delete','attendance_ids'], bluetooth: ['bluetooth/batch-delete','record_ids'], heartbeat: ['heartbeats/batch-delete','record_ids'] }; + const [path, key] = apiMap[logType] || apiMap.location; + _batchDelete('log-sel-cb', path, key, '记录', loadDataLog); + } async function batchDeleteNoCoordLocations() { const deviceId = document.getElementById('locDeviceSelect').value || null; @@ -2608,10 +2628,11 @@ const tbody = document.getElementById('alarmsTableBody'); if (items.length === 0) { - tbody.innerHTML = '没有告警记录'; + tbody.innerHTML = '没有告警记录'; } else { tbody.innerHTML = items.map(a => ` + ${escapeHtml(a.device_id || '-')} ${alarmTypeName(a.alarm_type)} ${escapeHtml(a.alarm_source || '-')} @@ -2629,10 +2650,11 @@ `).join(''); } + document.getElementById('alarmSelectAll').checked = false; renderPagination('alarmsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadAlarms'); } catch (err) { showToast('加载告警失败: ' + err.message, 'error'); - document.getElementById('alarmsTableBody').innerHTML = '加载失败'; + document.getElementById('alarmsTableBody').innerHTML = '加载失败'; } finally { hideLoading('alarmsLoading'); } @@ -2698,7 +2720,7 @@ const tbody = document.getElementById('attendanceTableBody'); if (items.length === 0) { - tbody.innerHTML = '没有考勤记录'; + tbody.innerHTML = '没有考勤记录'; } else { tbody.innerHTML = items.map(a => { const posStr = a.address || (a.latitude != null ? `${Number(a.latitude).toFixed(6)}, ${Number(a.longitude).toFixed(6)}` : '-'); @@ -2709,6 +2731,7 @@ const srcLabel = {'device':' 设备','bluetooth':' 蓝牙','fence':' 围栏'}[a.attendance_source] || a.attendance_source || '设备'; const srcColor = {'device':'#9ca3af','bluetooth':'#818cf8','fence':'#34d399'}[a.attendance_source] || '#9ca3af'; return ` + ${escapeHtml(a.device_id || '-')} ${attendanceTypeName(a.attendance_type)} ${srcLabel} @@ -2720,10 +2743,11 @@ `; }).join(''); } + document.getElementById('attSelectAll').checked = false; renderPagination('attendancePagination', data.total || 0, data.page || p, data.page_size || ps, 'loadAttendance'); } catch (err) { showToast('加载考勤记录失败: ' + err.message, 'error'); - document.getElementById('attendanceTableBody').innerHTML = '加载失败'; + document.getElementById('attendanceTableBody').innerHTML = '加载失败'; } finally { hideLoading('attendanceLoading'); } @@ -2764,7 +2788,7 @@ const tbody = document.getElementById('bluetoothTableBody'); if (items.length === 0) { - tbody.innerHTML = '没有蓝牙记录'; + tbody.innerHTML = '没有蓝牙记录'; } else { tbody.innerHTML = items.map(b => { const typeIcon = b.record_type === 'punch' ? '' : ''; @@ -2777,6 +2801,7 @@ const battStr = b.beacon_battery != null ? `${Number(b.beacon_battery).toFixed(2)}${b.beacon_battery_unit || 'V'}` : '-'; const attStr = b.attendance_type ? `${attendanceTypeName(b.attendance_type)}` : '-'; return ` + ${escapeHtml(b.device_id || '-')} ${typeIcon} ${typeName} ${escapeHtml(mac)} @@ -2788,10 +2813,11 @@ `; }).join(''); } + document.getElementById('btSelectAll').checked = false; renderPagination('bluetoothPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadBluetooth'); } catch (err) { showToast('加载蓝牙记录失败: ' + err.message, 'error'); - document.getElementById('bluetoothTableBody').innerHTML = '加载失败'; + document.getElementById('bluetoothTableBody').innerHTML = '加载失败'; } finally { hideLoading('bluetoothLoading'); } @@ -2919,7 +2945,7 @@ const tbody = document.getElementById('datalogTableBody'); if (items.length === 0) { - tbody.innerHTML = '暂无记录'; + tbody.innerHTML = '暂无记录'; } else { tbody.innerHTML = items.map(r => { const detail = _logDetail(type, r); @@ -2930,6 +2956,7 @@ const time = r.recorded_at || r.alarm_time || r.heartbeat_time || r.created_at; const typeBadge = _logTypeBadge(type); return ` + ${r.id} ${typeBadge} ${r.device_id || '-'} @@ -2941,9 +2968,10 @@ `; }).join(''); } + document.getElementById('logSelectAll').checked = false; renderPagination('datalogPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadDataLog'); } catch (err) { - document.getElementById('datalogTableBody').innerHTML = '加载失败: ' + escapeHtml(err.message) + ''; + document.getElementById('datalogTableBody').innerHTML = '加载失败: ' + escapeHtml(err.message) + ''; } finally { hideLoading('datalogLoading'); }