feat: 告警/考勤/蓝牙/数据日志页面添加批量删除功能

- 新增 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 <noreply@hapi.run>
This commit is contained in:
2026-03-31 02:03:21 +00:00
parent a97dcd07a5
commit 61c300bad8
4 changed files with 131 additions and 27 deletions

View File

@@ -553,6 +553,7 @@
<input type="date" id="alarmEndDate" style="width:160px">
<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>
</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>
@@ -560,6 +561,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>设备ID</th>
<th>类型</th>
<th>来源</th>
@@ -572,7 +574,7 @@
</tr>
</thead>
<tbody id="alarmsTableBody">
<tr><td colspan="9" class="text-center text-gray-500 py-8">加载中...</td></tr>
<tr><td colspan="10" class="text-center text-gray-500 py-8">加载中...</td></tr>
</tbody>
</table>
</div>
@@ -635,6 +637,7 @@
<input type="date" id="attEndDate" style="width:160px">
<button class="btn btn-primary" onclick="loadAttendance()"><i class="fas fa-search"></i> 查询</button>
<button class="btn btn-secondary" onclick="loadAttendance()"><i class="fas fa-sync-alt"></i> 刷新</button>
<button class="btn" style="background:#b91c1c;color:#fff" id="btnBatchDeleteAtt" onclick="batchDeleteSelectedAttendance()" disabled><i class="fas fa-trash-alt"></i> 删除选中 (<span id="attSelCount">0</span>)</button>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="attendanceLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
@@ -642,6 +645,7 @@
<table>
<thead>
<tr>
<th style="width:32px"><input type="checkbox" id="attSelectAll" onchange="toggleAllCheckboxes('att-sel-cb','attSelCount','btnBatchDeleteAtt',this.checked)" title="全选"></th>
<th>设备ID</th>
<th>类型</th>
<th>来源</th>
@@ -653,7 +657,7 @@
</tr>
</thead>
<tbody id="attendanceTableBody">
<tr><td colspan="8" class="text-center text-gray-500 py-8">加载中...</td></tr>
<tr><td colspan="9" class="text-center text-gray-500 py-8">加载中...</td></tr>
</tbody>
</table>
</div>
@@ -701,6 +705,7 @@
<input type="date" id="btEndDate" style="width:160px">
<button class="btn btn-primary" onclick="loadBluetooth()"><i class="fas fa-search"></i> 查询</button>
<button class="btn btn-secondary" onclick="loadBluetooth()"><i class="fas fa-sync-alt"></i> 刷新</button>
<button class="btn" style="background:#b91c1c;color:#fff" id="btnBatchDeleteBt" onclick="batchDeleteSelectedBluetooth()" disabled><i class="fas fa-trash-alt"></i> 删除选中 (<span id="btSelCount">0</span>)</button>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="bluetoothLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
@@ -708,6 +713,7 @@
<table>
<thead>
<tr>
<th style="width:32px"><input type="checkbox" id="btSelectAll" onchange="toggleAllCheckboxes('bt-sel-cb','btSelCount','btnBatchDeleteBt',this.checked)" title="全选"></th>
<th>设备ID</th>
<th>类型</th>
<th>信标MAC</th>
@@ -719,7 +725,7 @@
</tr>
</thead>
<tbody id="bluetoothTableBody">
<tr><td colspan="8" class="text-center text-gray-500 py-8">加载中...</td></tr>
<tr><td colspan="9" class="text-center text-gray-500 py-8">加载中...</td></tr>
</tbody>
</table>
</div>
@@ -906,6 +912,7 @@
<input type="date" id="logEndDate" style="width:150px">
<button class="btn btn-primary" onclick="loadDataLog()"><i class="fas fa-search"></i> 查询</button>
<button class="btn btn-secondary" onclick="loadDataLog()"><i class="fas fa-sync-alt"></i> 刷新</button>
<button class="btn" style="background:#b91c1c;color:#fff" id="btnBatchDeleteLog" onclick="batchDeleteSelectedDatalog()" disabled><i class="fas fa-trash-alt"></i> 删除选中 (<span id="logSelCount">0</span>)</button>
</div>
<div class="grid grid-cols-5 gap-3 mb-4">
<div class="stat-card" style="padding:14px;text-align:center;cursor:pointer" onclick="document.getElementById('logTypeFilter').value='location';loadDataLog()">
@@ -935,6 +942,7 @@
<table>
<thead>
<tr>
<th style="width:32px"><input type="checkbox" id="logSelectAll" onchange="toggleAllCheckboxes('log-sel-cb','logSelCount','btnBatchDeleteLog',this.checked)" title="全选"></th>
<th>ID</th>
<th>类型</th>
<th>设备ID</th>
@@ -946,7 +954,7 @@
</tr>
</thead>
<tbody id="datalogTableBody">
<tr><td colspan="8" class="text-center text-gray-500 py-8">选择筛选条件后点击查询</td></tr>
<tr><td colspan="9" class="text-center text-gray-500 py-8">选择筛选条件后点击查询</td></tr>
</tbody>
</table>
</div>
@@ -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 = '<tr><td colspan="9" class="text-center text-gray-500 py-8">没有告警记录</td></tr>';
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-gray-500 py-8">没有告警记录</td></tr>';
} 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 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>${escapeHtml(a.alarm_source || '-')}</td>
@@ -2629,10 +2650,11 @@
</tr>
`).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 = '<tr><td colspan="9" class="text-center text-red-400 py-8">加载失败</td></tr>';
document.getElementById('alarmsTableBody').innerHTML = '<tr><td colspan="10" class="text-center text-red-400 py-8">加载失败</td></tr>';
} finally {
hideLoading('alarmsLoading');
}
@@ -2698,7 +2720,7 @@
const tbody = document.getElementById('attendanceTableBody');
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-500 py-8">没有考勤记录</td></tr>';
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-gray-500 py-8">没有考勤记录</td></tr>';
} 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':'<i class="fas fa-mobile-alt"></i> 设备','bluetooth':'<i class="fab fa-bluetooth-b"></i> 蓝牙','fence':'<i class="fas fa-draw-polygon"></i> 围栏'}[a.attendance_source] || a.attendance_source || '设备';
const srcColor = {'device':'#9ca3af','bluetooth':'#818cf8','fence':'#34d399'}[a.attendance_source] || '#9ca3af';
return `<tr>
<td onclick="event.stopPropagation()"><input type="checkbox" class="att-sel-cb" value="${a.id}" onchange="updateSelCount('att-sel-cb','attSelCount','btnBatchDeleteAtt')"></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 style="color:${srcColor};font-size:12px">${srcLabel}</td>
@@ -2720,10 +2743,11 @@
</tr>`;
}).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 = '<tr><td colspan="8" class="text-center text-red-400 py-8">加载失败</td></tr>';
document.getElementById('attendanceTableBody').innerHTML = '<tr><td colspan="9" class="text-center text-red-400 py-8">加载失败</td></tr>';
} finally {
hideLoading('attendanceLoading');
}
@@ -2764,7 +2788,7 @@
const tbody = document.getElementById('bluetoothTableBody');
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-500 py-8">没有蓝牙记录</td></tr>';
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-gray-500 py-8">没有蓝牙记录</td></tr>';
} else {
tbody.innerHTML = items.map(b => {
const typeIcon = b.record_type === 'punch' ? '<i class="fas fa-fingerprint text-purple-400"></i>' : '<i class="fas fa-map-marker-alt text-cyan-400"></i>';
@@ -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 ? `<span class="${b.attendance_type === 'clock_in' ? 'text-green-400' : 'text-blue-400'}">${attendanceTypeName(b.attendance_type)}</span>` : '-';
return `<tr>
<td onclick="event.stopPropagation()"><input type="checkbox" class="bt-sel-cb" value="${b.id}" onchange="updateSelCount('bt-sel-cb','btSelCount','btnBatchDeleteBt')"></td>
<td class="font-mono text-xs">${escapeHtml(b.device_id || '-')}</td>
<td>${typeIcon} <span class="font-semibold">${typeName}</span></td>
<td class="font-mono text-xs">${escapeHtml(mac)}</td>
@@ -2788,10 +2813,11 @@
</tr>`;
}).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 = '<tr><td colspan="8" class="text-center text-red-400 py-8">加载失败</td></tr>';
document.getElementById('bluetoothTableBody').innerHTML = '<tr><td colspan="9" class="text-center text-red-400 py-8">加载失败</td></tr>';
} finally {
hideLoading('bluetoothLoading');
}
@@ -2919,7 +2945,7 @@
const tbody = document.getElementById('datalogTableBody');
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-500 py-8">暂无记录</td></tr>';
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-gray-500 py-8">暂无记录</td></tr>';
} 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 `<tr>
<td onclick="event.stopPropagation()"><input type="checkbox" class="log-sel-cb" value="${r.id}" onchange="updateSelCount('log-sel-cb','logSelCount','btnBatchDeleteLog')"></td>
<td class="font-mono text-xs">${r.id}</td>
<td>${typeBadge}</td>
<td>${r.device_id || '-'}</td>
@@ -2941,9 +2968,10 @@
</tr>`;
}).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 = '<tr><td colspan="8" class="text-center text-red-400 py-8">加载失败: ' + escapeHtml(err.message) + '</td></tr>';
document.getElementById('datalogTableBody').innerHTML = '<tr><td colspan="9" class="text-center text-red-400 py-8">加载失败: ' + escapeHtml(err.message) + '</td></tr>';
} finally {
hideLoading('datalogLoading');
}