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 @@
+
@@ -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');
}