feat: 围栏Tab布局重构、低精度过滤、蓝牙考勤去重、考勤删除API
- 围栏管理页面Tab移至顶部,设备绑定Tab隐藏地图全屏展示绑定矩阵 - 位置追踪新增"低精度"按钮,隐藏LBS/WiFi点(地图+折线+表格联动) - 移除LBS/WiFi精度半径圆圈,仅通过标记颜色区分定位类型 - 蓝牙打卡(0xB2)自动创建考勤记录,含去重和WebSocket广播 - 新增考勤批量删除和单条删除API - fence_checker补充json导入 via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
@@ -431,8 +431,8 @@
|
||||
<div class="page-main-content" style="position:relative">
|
||||
<button class="panel-expand-btn" onclick="toggleSidePanel('locSidePanel')" title="展开设备面板"><i class="fas fa-chevron-right"></i></button>
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||
<select id="locDeviceSelect" style="width:200px">
|
||||
<option value="">选择设备...</option>
|
||||
<select id="locDeviceSelect" style="width:200px" onchange="onLocDeviceSelectChange(this.value)">
|
||||
<option value="">全部设备</option>
|
||||
</select>
|
||||
<select id="locTypeFilter" style="width:150px">
|
||||
<option value="">全部类型</option>
|
||||
@@ -448,6 +448,7 @@
|
||||
<button class="btn btn-primary" onclick="loadTrack()"><i class="fas fa-route"></i> 显示轨迹</button>
|
||||
<button class="btn btn-primary" onclick="playTrack()" style="background:#7c3aed"><i class="fas fa-play"></i> 路径回放</button>
|
||||
<button class="btn btn-success" onclick="loadLatestPosition()"><i class="fas fa-crosshairs"></i> 最新位置</button>
|
||||
<button id="btnHideLowPrecision" class="btn btn-secondary" onclick="toggleHideLowPrecision()" title="隐藏 LBS/WiFi 低精度定位点,仅显示 GPS"><i class="fas fa-eye"></i> 低精度</button>
|
||||
<button class="btn btn-secondary" onclick="loadLocationRecords()"><i class="fas fa-list"></i> 查询记录</button>
|
||||
<button class="btn" style="background:#dc2626;color:#fff" onclick="batchDeleteNoCoordLocations()"><i class="fas fa-broom"></i> 清除无坐标</button>
|
||||
<button class="btn" style="background:#b91c1c;color:#fff" id="btnBatchDeleteLoc" onclick="batchDeleteSelectedLocations()" disabled><i class="fas fa-trash-alt"></i> 删除选中 (<span id="locSelCount">0</span>)</button>
|
||||
@@ -648,10 +649,11 @@
|
||||
<th>电量/信号</th>
|
||||
<th>基站</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="attendanceTableBody">
|
||||
<tr><td colspan="7" class="text-center text-gray-500 py-8">加载中...</td></tr>
|
||||
<tr><td colspan="8" class="text-center text-gray-500 py-8">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -825,27 +827,27 @@
|
||||
<!-- Right: Main Content -->
|
||||
<div class="page-main-content" style="display:flex;flex-direction:column;flex:1;overflow:hidden">
|
||||
<button class="panel-expand-btn" onclick="toggleSidePanel('fenceSidePanel')" title="展开围栏面板"><i class="fas fa-chevron-right"></i></button>
|
||||
<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">
|
||||
<input type="text" id="fenceMapSearchInput" placeholder="搜索地点..." style="flex:1" onkeydown="if(event.key==='Enter')searchFenceMapLocation()">
|
||||
<button class="btn btn-secondary" onclick="searchFenceMapLocation()"><i class="fas fa-search"></i></button>
|
||||
</div>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn btn-primary" onclick="showAddFenceModal()"><i class="fas fa-plus"></i> 添加围栏</button>
|
||||
<!-- Top Tabs -->
|
||||
<div style="display:flex;border-bottom:1px solid #374151;background:#1f2937;border-radius:8px 8px 0 0;margin-bottom:12px">
|
||||
<button id="fenceTabList" class="fence-tab active" onclick="switchFenceTab('list')"><i class="fas fa-map-marked-alt"></i> 围栏管理</button>
|
||||
<button id="fenceTabBindings" class="fence-tab" onclick="switchFenceTab('bindings')"><i class="fas fa-link"></i> 设备绑定</button>
|
||||
</div>
|
||||
<div id="fenceMapContainer" style="flex:1;min-height:400px;border-radius:12px;border:1px solid #374151;margin-bottom:12px;position:relative;">
|
||||
<div id="fenceMap" style="height:100%;width:100%;border-radius:12px;"></div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" style="max-height:300px;overflow-y:auto">
|
||||
<div id="fencesLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
|
||||
<!-- Tabs -->
|
||||
<div style="display:flex;border-bottom:1px solid #374151;background:#1f2937">
|
||||
<button id="fenceTabList" class="fence-tab active" onclick="switchFenceTab('list')"><i class="fas fa-list"></i> 围栏列表</button>
|
||||
<button id="fenceTabBindings" class="fence-tab" onclick="switchFenceTab('bindings')"><i class="fas fa-link"></i> 设备绑定</button>
|
||||
<!-- Tab Content: Fence List + Map -->
|
||||
<div id="fenceTabContentList" style="display:flex;flex-direction:column;flex:1;overflow:hidden">
|
||||
<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">
|
||||
<input type="text" id="fenceMapSearchInput" placeholder="搜索地点..." style="flex:1" onkeydown="if(event.key==='Enter')searchFenceMapLocation()">
|
||||
<button class="btn btn-secondary" onclick="searchFenceMapLocation()"><i class="fas fa-search"></i></button>
|
||||
</div>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn btn-primary" onclick="showAddFenceModal()"><i class="fas fa-plus"></i> 添加围栏</button>
|
||||
</div>
|
||||
<!-- Tab: Fence List -->
|
||||
<div id="fenceTabContentList" class="overflow-x-auto">
|
||||
<div id="fenceMapContainer" style="flex:1;min-height:400px;border-radius:12px;border:1px solid #374151;margin-bottom:12px;position:relative;">
|
||||
<div id="fenceMap" style="height:100%;width:100%;border-radius:12px;"></div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" style="max-height:300px;overflow-y:auto">
|
||||
<div id="fencesLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -864,24 +866,21 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Tab: Device Bindings -->
|
||||
<div id="fenceTabContentBindings" style="display:none;padding:12px">
|
||||
<div style="display:flex;gap:8px;margin-bottom:10px;align-items:center">
|
||||
<select id="fenceBindSelect" style="flex:1;max-width:240px" onchange="loadFenceBindingTab()">
|
||||
<option value="">选择围栏...</option>
|
||||
</select>
|
||||
<select id="fenceBindDeviceAdd" style="flex:1;max-width:240px">
|
||||
<option value="">选择设备添加绑定...</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" style="white-space:nowrap" onclick="quickBindDevice()"><i class="fas fa-plus"></i> 绑定</button>
|
||||
</div>
|
||||
<div id="fenceBindTableWrap" class="overflow-x-auto">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>设备名称</th><th>IMEI</th><th>围栏状态</th><th>最后检测</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody id="fenceBindTableBody">
|
||||
<tr><td colspan="5" class="text-center text-gray-500 py-4">请选择围栏</td></tr>
|
||||
</div>
|
||||
<!-- Tab Content: Device Bindings -->
|
||||
<div id="fenceTabContentBindings" style="display:none;flex-direction:column;flex:1;overflow:hidden">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-3">
|
||||
<button class="btn btn-primary" onclick="loadBindingMatrix()"><i class="fas fa-sync-alt"></i> 刷新</button>
|
||||
<span style="color:#9ca3af;font-size:12px"><i class="fas fa-info-circle"></i> 勾选表示绑定设备到围栏,取消勾选自动解绑</span>
|
||||
<div style="flex:1"></div>
|
||||
<button id="fenceBindSaveBtn" class="btn btn-primary" onclick="saveBindingMatrix()"><i class="fas fa-save"></i> 保存更改</button>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" style="flex:1;overflow:auto">
|
||||
<div id="fenceBindMatrixWrap" class="overflow-x-auto" style="height:100%;overflow-y:auto">
|
||||
<table id="fenceBindMatrix" style="font-size:12px">
|
||||
<thead id="fenceBindMatrixHead" style="position:sticky;top:0;background:#1f2937;z-index:1"></thead>
|
||||
<tbody id="fenceBindMatrixBody">
|
||||
<tr><td class="text-center text-gray-500 py-4">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1307,7 +1306,7 @@
|
||||
switch (page) {
|
||||
case 'dashboard': loadDashboard(); dashboardInterval = setInterval(loadDashboard, 30000); break;
|
||||
case 'devices': loadDevices(); break;
|
||||
case 'locations': initLocationMap(); loadDeviceSelectors(); loadLocationRecords(1); break;
|
||||
case 'locations': initLocationMap(); loadDeviceSelectors(); break;
|
||||
case 'alarms': loadAlarmStats(); loadAlarms(); loadDeviceSelectors(); break;
|
||||
case 'attendance': loadAttendanceStats(); loadAttendance(); loadDeviceSelectors(); break;
|
||||
case 'bluetooth': loadBluetooth(); loadDeviceSelectors(); break;
|
||||
@@ -1454,7 +1453,16 @@
|
||||
if (select) select.value = deviceId;
|
||||
const activeCard = document.querySelector(`#locPanelList .panel-item[data-device-id="${deviceId}"]`);
|
||||
if (activeCard) activeCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
// Don't auto-locate on panel selection, user clicks "最新位置" manually
|
||||
// Reload location records filtered by selected device
|
||||
loadLocationRecords(1);
|
||||
}
|
||||
|
||||
function onLocDeviceSelectChange(deviceId) {
|
||||
selectedPanelDeviceId = deviceId;
|
||||
document.querySelectorAll('#locPanelList .panel-item').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.deviceId == deviceId);
|
||||
});
|
||||
loadLocationRecords(1);
|
||||
}
|
||||
|
||||
function selectPanelBeacon(beaconId) {
|
||||
@@ -1509,7 +1517,11 @@
|
||||
if (currentPage === 'locations' && document.getElementById('locPanelList')) {
|
||||
const sorted = sortDevicesByActivity(devices);
|
||||
renderDevicePanel(sorted);
|
||||
if (!selectedPanelDeviceId) autoSelectActiveDevice(sorted);
|
||||
if (!selectedPanelDeviceId) {
|
||||
autoSelectActiveDevice(sorted); // this calls selectPanelDevice → loadLocationRecords
|
||||
}
|
||||
// If no devices or already selected, ensure records are loaded
|
||||
if (!sorted.length || selectedPanelDeviceId) loadLocationRecords(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load device selectors:', err);
|
||||
@@ -2247,10 +2259,12 @@
|
||||
}
|
||||
|
||||
function clearMapOverlays() {
|
||||
mapMarkers.forEach(m => locationMap.remove(m));
|
||||
mapMarkers.forEach(m => { if (!m._lpHidden) locationMap.remove(m); });
|
||||
mapMarkers = [];
|
||||
mapInfoWindows = [];
|
||||
if (mapPolyline) { locationMap.remove(mapPolyline); mapPolyline = null; }
|
||||
_trackPolyline = null;
|
||||
_trackLocations = null;
|
||||
if (trackPlayTimer) { cancelAnimationFrame(trackPlayTimer); trackPlayTimer = null; }
|
||||
if (trackMovingMarker) { locationMap.remove(trackMovingMarker); trackMovingMarker = null; }
|
||||
if (_focusInfoWindow) { _focusInfoWindow.close(); _focusInfoWindow = null; }
|
||||
@@ -2259,6 +2273,62 @@
|
||||
if (legend) legend.style.display = 'none';
|
||||
}
|
||||
|
||||
// ---- Hide low-precision toggle ----
|
||||
let _hideLowPrecision = false;
|
||||
function toggleHideLowPrecision() {
|
||||
_hideLowPrecision = !_hideLowPrecision;
|
||||
const btn = document.getElementById('btnHideLowPrecision');
|
||||
if (_hideLowPrecision) {
|
||||
btn.style.background = '#b91c1c';
|
||||
btn.style.color = '#fff';
|
||||
btn.innerHTML = '<i class="fas fa-eye-slash"></i> 低精度';
|
||||
} else {
|
||||
btn.style.background = '';
|
||||
btn.style.color = '';
|
||||
btn.innerHTML = '<i class="fas fa-eye"></i> 低精度';
|
||||
}
|
||||
// Re-apply to existing track markers
|
||||
_applyLowPrecisionFilter();
|
||||
}
|
||||
function _isLowPrecision(locationType) {
|
||||
const t = (locationType || '').toLowerCase();
|
||||
return t.startsWith('lbs') || t.startsWith('wifi');
|
||||
}
|
||||
function _applyLowPrecisionFilter() {
|
||||
// Toggle visibility of low-precision markers stored with _lpFlag
|
||||
mapMarkers.forEach(m => {
|
||||
if (m._lpFlag) {
|
||||
if (_hideLowPrecision) { m.hide ? m.hide() : m.setMap(null); m._lpHidden = true; }
|
||||
else { m.show ? m.show() : m.setMap(locationMap); m._lpHidden = false; }
|
||||
}
|
||||
});
|
||||
// Also filter the track polyline if exists — rebuild path without LP points
|
||||
if (_trackPolyline && _trackLocations) {
|
||||
const path = [];
|
||||
_trackLocations.forEach(loc => {
|
||||
if (_hideLowPrecision && _isLowPrecision(loc.location_type)) return;
|
||||
const lat = loc.latitude || loc.lat;
|
||||
const lng = loc.longitude || loc.lng || loc.lon;
|
||||
if (lat && lng) {
|
||||
const [mLat, mLng] = toMapCoord(lat, lng);
|
||||
path.push([mLng, mLat]);
|
||||
}
|
||||
});
|
||||
_trackPolyline.setPath(path);
|
||||
}
|
||||
// Filter table rows
|
||||
document.querySelectorAll('#locationsTableBody tr[data-loc-type]').forEach(tr => {
|
||||
if (_hideLowPrecision && _isLowPrecision(tr.dataset.locType)) {
|
||||
tr.style.display = 'none';
|
||||
} else {
|
||||
tr.style.display = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let _trackPolyline = null;
|
||||
let _trackLocations = null;
|
||||
|
||||
async function loadTrack() {
|
||||
const deviceId = document.getElementById('locDeviceSelect').value;
|
||||
if (!deviceId) { showToast('请选择设备', 'error'); return; }
|
||||
@@ -2322,25 +2392,10 @@
|
||||
fillOpacity: isLbs ? 0.6 : 0.9,
|
||||
zIndex: 120, cursor: 'pointer',
|
||||
});
|
||||
marker.setMap(locationMap);
|
||||
// Add accuracy radius ring for LBS points (~1000m) and WiFi (~80m)
|
||||
if (isLbs && !isFirst && !isLast) {
|
||||
const ring = new AMap.Circle({
|
||||
center: [mLng, mLat], radius: 1000,
|
||||
strokeColor: '#f59e0b', strokeWeight: 1, strokeOpacity: 0.3, strokeStyle: 'dashed',
|
||||
fillColor: '#f59e0b', fillOpacity: 0.05, bubble: true,
|
||||
});
|
||||
ring.setMap(locationMap);
|
||||
mapMarkers.push(ring);
|
||||
} else if (isWifi && !isFirst && !isLast) {
|
||||
const ring = new AMap.Circle({
|
||||
center: [mLng, mLat], radius: 80,
|
||||
strokeColor: '#06b6d4', strokeWeight: 1, strokeOpacity: 0.3, strokeStyle: 'dashed',
|
||||
fillColor: '#06b6d4', fillOpacity: 0.05, bubble: true,
|
||||
});
|
||||
ring.setMap(locationMap);
|
||||
mapMarkers.push(ring);
|
||||
}
|
||||
const isLP = (isLbs || isWifi) && !isFirst && !isLast;
|
||||
marker._lpFlag = isLP;
|
||||
if (_hideLowPrecision && isLP) marker._lpHidden = true;
|
||||
else marker.setMap(locationMap);
|
||||
const label = isFirst ? `起点 (1/${total})` : isLast ? `终点 (${i+1}/${total})` : `第 ${i+1}/${total} 点`;
|
||||
const content = _buildInfoContent(label, loc, lat, lng);
|
||||
const infoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -5) });
|
||||
@@ -2350,18 +2405,31 @@
|
||||
}
|
||||
});
|
||||
|
||||
if (path.length > 1) {
|
||||
mapPolyline = new AMap.Polyline({ path, strokeColor: '#3b82f6', strokeWeight: 3, strokeOpacity: 0.8, lineJoin: 'round' });
|
||||
// Store track data for LP filtering
|
||||
_trackLocations = locations;
|
||||
|
||||
// Build path excluding LP points if hidden
|
||||
const filteredPath = _hideLowPrecision
|
||||
? path.filter((_, i) => {
|
||||
const lt = (locations[i]?.location_type || '').toLowerCase();
|
||||
const isFirst = i === 0, isLast = i === locations.length - 1;
|
||||
return isFirst || isLast || !(lt.startsWith('lbs') || lt.startsWith('wifi'));
|
||||
})
|
||||
: path;
|
||||
|
||||
if (filteredPath.length > 1) {
|
||||
mapPolyline = new AMap.Polyline({ path: filteredPath, strokeColor: '#3b82f6', strokeWeight: 3, strokeOpacity: 0.8, lineJoin: 'round' });
|
||||
mapPolyline.setMap(locationMap);
|
||||
_trackPolyline = mapPolyline;
|
||||
locationMap.setFitView([mapPolyline], false, [50, 50, 50, 50]);
|
||||
} else if (path.length === 1) {
|
||||
locationMap.setCenter(path[0]);
|
||||
} else if (filteredPath.length === 1) {
|
||||
locationMap.setCenter(filteredPath[0]);
|
||||
locationMap.setZoom(15);
|
||||
}
|
||||
|
||||
const legend = document.getElementById('mapLegend');
|
||||
if (legend) legend.style.display = 'block';
|
||||
showToast(`已加载 ${total} 个轨迹点`);
|
||||
showToast(`已加载 ${total} 个轨迹点${_hideLowPrecision ? ' (已隐藏低精度)' : ''}`);
|
||||
} catch (err) {
|
||||
showToast('加载轨迹失败: ' + err.message, 'error');
|
||||
}
|
||||
@@ -2429,25 +2497,6 @@
|
||||
const [mLat, mLng] = toMapCoord(lat, lng);
|
||||
const marker = new AMap.Marker({ position: [mLng, mLat] });
|
||||
marker.setMap(locationMap);
|
||||
// Add accuracy radius for LBS/WiFi latest position
|
||||
const _lt = loc.location_type || '';
|
||||
if (_lt.startsWith('lbs')) {
|
||||
const ring = new AMap.Circle({
|
||||
center: [mLng, mLat], radius: 1000,
|
||||
strokeColor: '#f59e0b', strokeWeight: 1, strokeOpacity: 0.4, strokeStyle: 'dashed',
|
||||
fillColor: '#f59e0b', fillOpacity: 0.06, bubble: true,
|
||||
});
|
||||
ring.setMap(locationMap);
|
||||
mapMarkers.push(ring);
|
||||
} else if (_lt.startsWith('wifi')) {
|
||||
const ring = new AMap.Circle({
|
||||
center: [mLng, mLat], radius: 80,
|
||||
strokeColor: '#06b6d4', strokeWeight: 1, strokeOpacity: 0.4, strokeStyle: 'dashed',
|
||||
fillColor: '#06b6d4', fillOpacity: 0.06, bubble: true,
|
||||
});
|
||||
ring.setMap(locationMap);
|
||||
mapMarkers.push(ring);
|
||||
}
|
||||
const infoContent = _buildInfoContent('实时定位', loc, lat, lng);
|
||||
const infoWindow = new AMap.InfoWindow({ content: infoContent, offset: new AMap.Pixel(0, -30) });
|
||||
infoWindow.open(locationMap, [mLng, mLat]);
|
||||
@@ -2493,7 +2542,8 @@
|
||||
tbody.innerHTML = items.map(l => {
|
||||
const q = _locQuality(l);
|
||||
const hasCoord = l.latitude != null && l.longitude != null;
|
||||
return `<tr style="cursor:${hasCoord?'pointer':'default'}" ${hasCoord ? `onclick="focusMapPoint(${l.id})"` : ''}>
|
||||
const lpHide = _hideLowPrecision && _isLowPrecision(l.location_type);
|
||||
return `<tr data-loc-type="${l.location_type || ''}" style="cursor:${hasCoord?'pointer':'default'}${lpHide ? ';display:none' : ''}" ${hasCoord ? `onclick="focusMapPoint(${l.id})"` : ''}>
|
||||
<td onclick="event.stopPropagation()"><input type="checkbox" class="loc-sel-cb" value="${l.id}" onchange="updateLocSelCount()"></td>
|
||||
<td class="font-mono text-xs">${escapeHtml(l.device_id || '-')}</td>
|
||||
<td>${_locTypeLabel(l.location_type)}</td>
|
||||
@@ -2648,7 +2698,7 @@
|
||||
const tbody = document.getElementById('attendanceTableBody');
|
||||
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-gray-500 py-8">没有考勤记录</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="8" 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)}` : '-');
|
||||
@@ -2666,18 +2716,31 @@
|
||||
<td class="text-xs">${battStr} ${sigStr}</td>
|
||||
<td class="text-xs font-mono">${lbsStr}</td>
|
||||
<td class="text-xs text-gray-400">${formatTime(a.recorded_at)}</td>
|
||||
<td><button class="btn btn-sm" style="color:#ef4444;padding:2px 8px;font-size:11px" onclick="deleteAttendance(${a.id})"><i class="fas fa-trash-alt"></i></button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
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="7" class="text-center text-red-400 py-8">加载失败</td></tr>';
|
||||
document.getElementById('attendanceTableBody').innerHTML = '<tr><td colspan="8" class="text-center text-red-400 py-8">加载失败</td></tr>';
|
||||
} finally {
|
||||
hideLoading('attendanceLoading');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAttendance(id) {
|
||||
if (!confirm('确认删除此考勤记录?')) return;
|
||||
try {
|
||||
await apiCall(`${API_BASE}/attendance/${id}`, {method: 'DELETE'});
|
||||
showToast('删除成功', 'success');
|
||||
loadAttendance();
|
||||
loadAttendanceStats();
|
||||
} catch (err) {
|
||||
showToast('删除失败: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== BLUETOOTH ====================
|
||||
async function loadBluetooth(page) {
|
||||
if (page) pageState.bluetooth.page = page;
|
||||
@@ -3471,89 +3534,167 @@
|
||||
function switchFenceTab(tab) {
|
||||
document.getElementById('fenceTabList').classList.toggle('active', tab === 'list');
|
||||
document.getElementById('fenceTabBindings').classList.toggle('active', tab === 'bindings');
|
||||
document.getElementById('fenceTabContentList').style.display = tab === 'list' ? '' : 'none';
|
||||
document.getElementById('fenceTabContentBindings').style.display = tab === 'bindings' ? '' : 'none';
|
||||
if (tab === 'bindings') initFenceBindingTab();
|
||||
document.getElementById('fenceTabContentList').style.display = tab === 'list' ? 'flex' : 'none';
|
||||
document.getElementById('fenceTabContentBindings').style.display = tab === 'bindings' ? 'flex' : 'none';
|
||||
if (tab === 'bindings') loadBindingMatrix();
|
||||
}
|
||||
|
||||
async function initFenceBindingTab() {
|
||||
const sel = document.getElementById('fenceBindSelect');
|
||||
if (sel.options.length <= 1) {
|
||||
// Populate fence dropdown
|
||||
try {
|
||||
const data = await apiCall(`${API_BASE}/fences?page=1&page_size=100`);
|
||||
const items = data.items || [];
|
||||
sel.innerHTML = '<option value="">选择围栏...</option>' + items.map(f =>
|
||||
`<option value="${f.id}">${escapeHtml(f.name)} (${f.fence_type === 'circle' ? '圆形' : '多边形'})</option>`
|
||||
).join('');
|
||||
} catch (_) {}
|
||||
}
|
||||
// Populate device add dropdown
|
||||
const devSel = document.getElementById('fenceBindDeviceAdd');
|
||||
if (devSel.options.length <= 1) {
|
||||
try {
|
||||
const devData = await apiCall(`${API_BASE}/devices?page=1&page_size=100`);
|
||||
const allDevices = devData.items || [];
|
||||
devSel.innerHTML = '<option value="">选择设备添加绑定...</option>' + allDevices.map(d =>
|
||||
`<option value="${d.id}">${escapeHtml(d.name || d.imei)} (${d.imei})</option>`
|
||||
).join('');
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
let _bindMatrixState = {}; // { "fenceId-deviceId": true/false }
|
||||
let _bindMatrixOriginal = {}; // original state for diff
|
||||
let _bindFences = [];
|
||||
let _bindDevices = [];
|
||||
|
||||
async function loadFenceBindingTab() {
|
||||
const fenceId = document.getElementById('fenceBindSelect').value;
|
||||
const tbody = document.getElementById('fenceBindTableBody');
|
||||
if (!fenceId) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-500 py-4">请选择围栏</td></tr>';
|
||||
return;
|
||||
}
|
||||
async function loadBindingMatrix() {
|
||||
const thead = document.getElementById('fenceBindMatrixHead');
|
||||
const tbody = document.getElementById('fenceBindMatrixBody');
|
||||
try {
|
||||
const devices = await apiCall(`${API_BASE}/fences/${fenceId}/devices`);
|
||||
if (devices.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-500 py-4">暂无绑定设备,请从上方添加</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = devices.map(d => `<tr>
|
||||
<td>${escapeHtml(d.device_name || '-')}</td>
|
||||
<td class="font-mono text-xs">${escapeHtml(d.imei || '-')}</td>
|
||||
<td><span class="badge ${d.is_inside ? 'badge-online' : 'badge-offline'}">${d.is_inside ? '围栏内' : '围栏外'}</span></td>
|
||||
<td class="text-xs text-gray-400">${d.last_check_at ? formatTime(d.last_check_at) : '-'}</td>
|
||||
<td><button class="btn btn-sm" style="color:#ef4444;font-size:11px" onclick="quickUnbindDevice(${fenceId},${d.device_id},'${escapeHtml(d.device_name||d.imei||"")}')"><i class="fas fa-unlink"></i> 解绑</button></td>
|
||||
</tr>`).join('');
|
||||
const [fenceData, deviceData] = await Promise.all([
|
||||
apiCall(`${API_BASE}/fences?page=1&page_size=100`),
|
||||
apiCall(`${API_BASE}/devices?page=1&page_size=100`),
|
||||
]);
|
||||
_bindFences = fenceData.items || [];
|
||||
_bindDevices = deviceData.items || [];
|
||||
|
||||
if (!_bindFences.length) {
|
||||
thead.innerHTML = '';
|
||||
tbody.innerHTML = '<tr><td class="text-center text-gray-500 py-4">暂无围栏,请先创建</td></tr>';
|
||||
return;
|
||||
}
|
||||
if (!_bindDevices.length) {
|
||||
thead.innerHTML = '';
|
||||
tbody.innerHTML = '<tr><td class="text-center text-gray-500 py-4">暂无设备</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch bindings for all fences in parallel
|
||||
const bindingsArr = await Promise.all(
|
||||
_bindFences.map(f => apiCall(`${API_BASE}/fences/${f.id}/devices`).catch(() => []))
|
||||
);
|
||||
|
||||
// Build state
|
||||
_bindMatrixState = {};
|
||||
_bindMatrixOriginal = {};
|
||||
_bindFences.forEach((f, i) => {
|
||||
const bound = bindingsArr[i] || [];
|
||||
const boundIds = new Set(bound.map(b => b.device_id));
|
||||
_bindDevices.forEach(d => {
|
||||
const key = `${f.id}-${d.id}`;
|
||||
const val = boundIds.has(d.id);
|
||||
_bindMatrixState[key] = val;
|
||||
_bindMatrixOriginal[key] = val;
|
||||
});
|
||||
});
|
||||
|
||||
// Render header
|
||||
const fenceTypeName = t => ({circle:'圆',polygon:'多边',rectangle:'矩'}[t] || t);
|
||||
thead.innerHTML = `<tr>
|
||||
<th style="position:sticky;left:0;background:#1f2937;z-index:2;min-width:120px">设备 \\ 围栏</th>
|
||||
${_bindFences.map(f => `<th style="text-align:center;min-width:80px;font-size:11px;white-space:nowrap" title="${escapeHtml(f.name)}">${escapeHtml(f.name)}<br><span style="color:#6b7280;font-weight:normal">${fenceTypeName(f.fence_type)}</span></th>`).join('')}
|
||||
<th style="text-align:center;min-width:60px">全选</th>
|
||||
</tr>`;
|
||||
|
||||
// Render body
|
||||
tbody.innerHTML = _bindDevices.map(d => {
|
||||
const label = d.name || d.imei || d.id;
|
||||
const statusDot = d.status === 'online' ? '🟢' : '⚪';
|
||||
return `<tr>
|
||||
<td style="position:sticky;left:0;background:#111827;z-index:1;white-space:nowrap;font-size:12px">${statusDot} ${escapeHtml(label)}</td>
|
||||
${_bindFences.map(f => {
|
||||
const key = `${f.id}-${d.id}`;
|
||||
const checked = _bindMatrixState[key] ? 'checked' : '';
|
||||
return `<td style="text-align:center"><input type="checkbox" ${checked} onchange="_bindMatrixState['${key}']=this.checked;updateBindSaveBtn()"></td>`;
|
||||
}).join('')}
|
||||
<td style="text-align:center"><input type="checkbox" onchange="toggleDeviceRow(${d.id},this.checked)"></td>
|
||||
</tr>`;
|
||||
}).join('') + `<tr style="border-top:1px solid #374151">
|
||||
<td style="position:sticky;left:0;background:#111827;z-index:1;font-size:12px;color:#9ca3af">全选列</td>
|
||||
${_bindFences.map(f => `<td style="text-align:center"><input type="checkbox" onchange="toggleFenceCol(${f.id},this.checked)"></td>`).join('')}
|
||||
<td style="text-align:center"><input type="checkbox" onchange="toggleAllBindings(this.checked)"></td>
|
||||
</tr>`;
|
||||
updateBindSaveBtn();
|
||||
} catch (err) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-red-400 py-4">加载失败</td></tr>';
|
||||
thead.innerHTML = '';
|
||||
tbody.innerHTML = `<tr><td class="text-center text-red-400 py-4">加载失败: ${escapeHtml(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function quickBindDevice() {
|
||||
const fenceId = document.getElementById('fenceBindSelect').value;
|
||||
const deviceId = document.getElementById('fenceBindDeviceAdd').value;
|
||||
if (!fenceId) { showToast('请先选择围栏', 'info'); return; }
|
||||
if (!deviceId) { showToast('请选择要绑定的设备', 'info'); return; }
|
||||
try {
|
||||
await apiCall(`${API_BASE}/fences/${fenceId}/devices`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ device_ids: [parseInt(deviceId)] }),
|
||||
});
|
||||
showToast('绑定成功');
|
||||
loadFenceBindingTab();
|
||||
} catch (err) {
|
||||
showToast('绑定失败: ' + err.message, 'error');
|
||||
}
|
||||
function toggleDeviceRow(deviceId, checked) {
|
||||
_bindFences.forEach(f => {
|
||||
const key = `${f.id}-${deviceId}`;
|
||||
_bindMatrixState[key] = checked;
|
||||
});
|
||||
refreshMatrixCheckboxes();
|
||||
}
|
||||
|
||||
async function quickUnbindDevice(fenceId, deviceId, name) {
|
||||
if (!confirm(`确定解绑设备 "${name}" ?`)) return;
|
||||
function toggleFenceCol(fenceId, checked) {
|
||||
_bindDevices.forEach(d => {
|
||||
const key = `${fenceId}-${d.id}`;
|
||||
_bindMatrixState[key] = checked;
|
||||
});
|
||||
refreshMatrixCheckboxes();
|
||||
}
|
||||
|
||||
function toggleAllBindings(checked) {
|
||||
_bindFences.forEach(f => _bindDevices.forEach(d => {
|
||||
_bindMatrixState[`${f.id}-${d.id}`] = checked;
|
||||
}));
|
||||
refreshMatrixCheckboxes();
|
||||
}
|
||||
|
||||
function refreshMatrixCheckboxes() {
|
||||
const tbody = document.getElementById('fenceBindMatrixBody');
|
||||
const cbs = tbody.querySelectorAll('input[type="checkbox"]');
|
||||
cbs.forEach(cb => {
|
||||
const onchange = cb.getAttribute('onchange') || '';
|
||||
const m = onchange.match(/_bindMatrixState\['(\d+-\d+)'\]/);
|
||||
if (m) cb.checked = _bindMatrixState[m[1]] || false;
|
||||
});
|
||||
updateBindSaveBtn();
|
||||
}
|
||||
|
||||
function updateBindSaveBtn() {
|
||||
// Count changes
|
||||
let changes = 0;
|
||||
for (const key in _bindMatrixState) {
|
||||
if (_bindMatrixState[key] !== _bindMatrixOriginal[key]) changes++;
|
||||
}
|
||||
const btn = document.getElementById('fenceBindSaveBtn');
|
||||
if (btn) btn.innerHTML = changes > 0
|
||||
? `<i class="fas fa-save"></i> 保存更改 (${changes})`
|
||||
: `<i class="fas fa-save"></i> 保存更改`;
|
||||
}
|
||||
|
||||
async function saveBindingMatrix() {
|
||||
// Compute diffs per fence: which devices to bind, which to unbind
|
||||
const toBind = {}; // fenceId -> [deviceIds]
|
||||
const toUnbind = {}; // fenceId -> [deviceIds]
|
||||
for (const key in _bindMatrixState) {
|
||||
if (_bindMatrixState[key] === _bindMatrixOriginal[key]) continue;
|
||||
const [fenceId, deviceId] = key.split('-').map(Number);
|
||||
if (_bindMatrixState[key]) {
|
||||
(toBind[fenceId] = toBind[fenceId] || []).push(deviceId);
|
||||
} else {
|
||||
(toUnbind[fenceId] = toUnbind[fenceId] || []).push(deviceId);
|
||||
}
|
||||
}
|
||||
const ops = [];
|
||||
for (const fid in toBind) {
|
||||
ops.push(apiCall(`${API_BASE}/fences/${fid}/devices`, {
|
||||
method: 'POST', body: JSON.stringify({ device_ids: toBind[fid] }),
|
||||
}));
|
||||
}
|
||||
for (const fid in toUnbind) {
|
||||
ops.push(apiCall(`${API_BASE}/fences/${fid}/devices`, {
|
||||
method: 'DELETE', body: JSON.stringify({ device_ids: toUnbind[fid] }),
|
||||
}));
|
||||
}
|
||||
if (!ops.length) { showToast('没有更改', 'info'); return; }
|
||||
try {
|
||||
await apiCall(`${API_BASE}/fences/${fenceId}/devices`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ device_ids: [deviceId] }),
|
||||
});
|
||||
showToast('已解绑');
|
||||
loadFenceBindingTab();
|
||||
await Promise.all(ops);
|
||||
showToast(`保存成功 (${ops.length} 项操作)`, 'success');
|
||||
loadBindingMatrix(); // reload to sync state
|
||||
} catch (err) {
|
||||
showToast('解绑失败: ' + err.message, 'error');
|
||||
showToast('保存失败: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user