feat: 位置追踪优化、考勤去重、围栏考勤补充设备信息

- 地图轨迹点按定位类型区分颜色 (GPS蓝/WiFi青/LBS橙/蓝牙紫)
- LBS/WiFi定位点显示精度圈 (虚线圆, LBS~1km/WiFi~80m)
- 地图图例显示各定位类型颜色和精度范围
- 精度圈添加 bubble:true 防止遮挡轨迹点点击
- 点击列表记录直接在地图显示Marker+弹窗 (无需先加载轨迹)
- 修复3D地图setZoomAndCenter坐标偏移, 改用setCenter+setZoom
- 最新位置轮询超时从15s延长至30s (适配LBS慢响应)
- 考勤每日去重: 同设备同类型每天只记录一条 (fence/device/bluetooth通用)
- 围栏自动考勤补充设备电量/信号/基站信息 (从Device表和位置包获取)
- 考勤来源字段 attendance_source 区分 device/bluetooth/fence

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

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-30 04:26:29 +00:00
parent 1d06cc5415
commit 891344bfa0
8 changed files with 598 additions and 100 deletions

View File

@@ -132,6 +132,9 @@
.panel-item:hover .panel-item-actions { display: flex; }
.panel-action-btn { width: 24px; height: 24px; border-radius: 4px; border: none; background: #4b5563; color: #e5e7eb; font-size: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.panel-action-btn:hover { background: #2563eb; }
.fence-tab { padding: 8px 16px; border: none; background: transparent; color: #9ca3af; cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent; transition: all 0.2s; }
.fence-tab:hover { color: #e5e7eb; background: #374151; }
.fence-tab.active { color: #60a5fa; border-bottom-color: #3b82f6; background: rgba(59,130,246,0.1); }
.panel-footer { padding: 6px 12px; border-top: 1px solid #374151; font-size: 11px; color: #6b7280; text-align: center; flex-shrink: 0; }
.panel-expand-btn { position: absolute; left: 0; top: 50%; transform: translateY(-50%); background: #1f2937; border: 1px solid #374151; border-left: none; border-radius: 0 6px 6px 0; padding: 8px 4px; color: #9ca3af; cursor: pointer; z-index: 5; display: none; }
.side-panel.collapsed ~ .page-main-content .panel-expand-btn { display: block; }
@@ -402,7 +405,7 @@
<div class="guide-body">
<div class="guide-steps">
<div class="guide-step"><div class="step-num">1</div><div class="step-text"><strong>选择设备</strong>:从下拉框选择要查看的工牌设备</div></div>
<div class="guide-step"><div class="step-num">2</div><div class="step-text"><strong>最新位置</strong>点击绿色按钮获取设备最新定位并标注地图</div></div>
<div class="guide-step"><div class="step-num">2</div><div class="step-text"><strong>最新位置</strong>发送WHERE#指令获取设备实时定位(需设备在线)</div></div>
<div class="guide-step"><div class="step-num">3</div><div class="step-text"><strong>轨迹回放</strong>:设定日期范围后点击"显示轨迹"查看移动路径</div></div>
<div class="guide-step"><div class="step-num">4</div><div class="step-text"><strong>定位类型</strong>:支持 GPS / LBS 基站 / WiFi 及其 4G 变体筛选</div></div>
</div>
@@ -446,9 +449,17 @@
<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 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>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden mb-6" style="height: 500px;">
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden mb-6" style="height: 500px; position: relative;">
<div id="locationMap" style="height: 100%; width: 100%;"></div>
<div id="mapLegend" style="display:none;position:absolute;bottom:10px;left:10px;background:rgba(30,30,40,0.85);border:1px solid #4b5563;border-radius:8px;padding:8px 12px;font-size:11px;color:#d1d5db;z-index:10;line-height:1.8">
<span style="color:#3b82f6">&#9679;</span> GPS
<span style="color:#06b6d4;margin-left:8px">&#9679;</span> WiFi <span style="color:#9ca3af">(~80m)</span>
<span style="color:#f59e0b;margin-left:8px">&#9675;</span> LBS <span style="color:#9ca3af">(~1km)</span>
<span style="color:#a855f7;margin-left:8px">&#9679;</span> 蓝牙
</div>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="locationsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
@@ -456,6 +467,7 @@
<table>
<thead>
<tr>
<th style="width:32px"><input type="checkbox" id="locSelectAll" onchange="toggleAllLocCheckboxes(this.checked)" title="全选"></th>
<th>设备ID</th>
<th>类型</th>
<th>纬度</th>
@@ -468,7 +480,7 @@
</tr>
</thead>
<tbody id="locationsTableBody">
<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>
@@ -612,6 +624,12 @@
<option value="clock_in">签到</option>
<option value="clock_out">签退</option>
</select>
<select id="attSourceFilter" style="width:150px">
<option value="">全部来源</option>
<option value="device">设备打卡</option>
<option value="bluetooth">蓝牙打卡</option>
<option value="fence">围栏自动</option>
</select>
<input type="date" id="attStartDate" style="width:160px">
<input type="date" id="attEndDate" style="width:160px">
<button class="btn btn-primary" onclick="loadAttendance()"><i class="fas fa-search"></i> 查询</button>
@@ -625,6 +643,7 @@
<tr>
<th>设备ID</th>
<th>类型</th>
<th>来源</th>
<th>位置</th>
<th>电量/信号</th>
<th>基站</th>
@@ -632,7 +651,7 @@
</tr>
</thead>
<tbody id="attendanceTableBody">
<tr><td colspan="6" class="text-center text-gray-500 py-8">加载中...</td></tr>
<tr><td colspan="7" class="text-center text-gray-500 py-8">加载中...</td></tr>
</tbody>
</table>
</div>
@@ -818,9 +837,15 @@
<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:250px;overflow-y:auto">
<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>
<div class="overflow-x-auto">
<!-- 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>
</div>
<!-- Tab: Fence List -->
<div id="fenceTabContentList" class="overflow-x-auto">
<table>
<thead>
<tr>
@@ -839,6 +864,28 @@
</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>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@@ -1033,6 +1080,7 @@
let mapMarkers = [];
let mapPolyline = null;
let mapInfoWindows = []; // store {infoWindow, position} for each track point
let _locTableItems = []; // cached location records from table for on-the-fly marker creation
let trackPlayTimer = null;
let trackMovingMarker = null;
let dashAlarmChart = null;
@@ -1713,7 +1761,8 @@
trackMovingMarker.setMap(locationMap);
showToast('路径回放中...', 'info');
locationMap.setZoomAndCenter(16, positions[0]);
locationMap.setCenter(positions[0]);
locationMap.setZoom(16);
mapInfoWindows[0].infoWindow.open(locationMap, positions[0]);
let segIdx = 0; // current segment (from segIdx to segIdx+1)
@@ -1761,14 +1810,51 @@
}
// --- Focus a location on map by record id (called from table row) ---
let _focusInfoWindow = null; // single info window for table-click focus
let _focusMarker = null; // single marker for table-click focus
function focusMapPoint(locId) {
if (!locationMap) { showToast('请先加载轨迹或最新位置', 'error'); return; }
const item = mapInfoWindows.find(iw => iw.locId === locId);
if (!item) { showToast('该记录未在地图上显示,请先加载轨迹', 'info'); return; }
locationMap.setZoomAndCenter(17, item.position);
mapInfoWindows.forEach(iw => iw.infoWindow.close());
item.infoWindow.open(locationMap, item.position);
// Scroll map into view
if (!locationMap) initLocationMap();
// Wait for map init
if (!locationMap) { showToast('地图初始化中,请稍后重试', 'info'); return; }
// First try existing track markers
const existing = mapInfoWindows.find(iw => iw.locId === locId);
if (existing) {
locationMap.setCenter(existing.position);
locationMap.setZoom(17);
mapInfoWindows.forEach(iw => iw.infoWindow.close());
if (_focusInfoWindow) _focusInfoWindow.close();
if (_focusMarker) { locationMap.remove(_focusMarker); _focusMarker = null; }
existing.infoWindow.open(locationMap, existing.position);
document.getElementById('locationMap').scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
// Fallback: create marker on-the-fly from cached table data
const loc = _locTableItems.find(l => l.id === locId);
if (!loc) { showToast('记录数据不可用', 'info'); return; }
const lat = loc.latitude || loc.lat;
const lng = loc.longitude || loc.lng || loc.lon;
if (!lat || !lng) { showToast('该记录无坐标', 'info'); return; }
const [mLat, mLng] = toMapCoord(lat, lng);
// Remove previous focus marker
if (_focusMarker) { locationMap.remove(_focusMarker); _focusMarker = null; }
if (_focusInfoWindow) _focusInfoWindow.close();
// Create marker
_focusMarker = new AMap.Marker({ position: [mLng, mLat] });
_focusMarker.setMap(locationMap);
// Create and open info window
const content = _buildInfoContent('位置记录', loc, lat, lng);
_focusInfoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -30) });
_focusInfoWindow.open(locationMap, [mLng, mLat]);
_focusMarker.on('click', () => _focusInfoWindow.open(locationMap, [mLng, mLat]));
locationMap.setCenter([mLng, mLat]);
locationMap.setZoom(17);
document.getElementById('locationMap').scrollIntoView({ behavior: 'smooth', block: 'center' });
}
@@ -1804,6 +1890,59 @@
}
}
function toggleAllLocCheckboxes(checked) {
document.querySelectorAll('.loc-sel-cb').forEach(cb => { cb.checked = checked; });
updateLocSelCount();
}
function updateLocSelCount() {
const count = document.querySelectorAll('.loc-sel-cb:checked').length;
document.getElementById('locSelCount').textContent = count;
document.getElementById('btnBatchDeleteLoc').disabled = count === 0;
}
async function batchDeleteSelectedLocations() {
const ids = Array.from(document.querySelectorAll('.loc-sel-cb:checked')).map(cb => parseInt(cb.value));
if (ids.length === 0) { showToast('请先勾选要删除的记录', 'info'); return; }
if (!confirm(`确定批量删除选中的 ${ids.length} 条位置记录?`)) return;
try {
const result = await apiCall(`${API_BASE}/locations/batch-delete`, {
method: 'POST',
body: JSON.stringify({ location_ids: ids }),
});
showToast(`已删除 ${result.deleted} 条记录`);
loadLocationRecords();
} catch (err) {
showToast('批量删除失败: ' + err.message, 'error');
}
}
async function batchDeleteNoCoordLocations() {
const deviceId = document.getElementById('locDeviceSelect').value || null;
const startTime = document.getElementById('locStartDate').value || null;
const endTime = document.getElementById('locEndDate').value || null;
const filterDesc = [
deviceId ? `设备ID=${deviceId}` : '所有设备',
startTime ? `${startTime}` : '',
endTime ? `${endTime}` : '',
].filter(Boolean).join(', ');
if (!confirm(`确定删除无坐标(经纬度为空)的位置记录?\n范围: ${filterDesc}\n\n此操作不可撤销!`)) return;
try {
const body = {};
if (deviceId) body.device_id = parseInt(deviceId);
if (startTime) body.start_time = startTime + 'T00:00:00';
if (endTime) body.end_time = endTime + 'T23:59:59';
const result = await apiCall(`${API_BASE}/locations/delete-no-coords`, {
method: 'POST',
body: JSON.stringify(body),
});
showToast(`已清除 ${result.deleted} 条无坐标记录`);
loadLocationRecords();
} catch (err) {
showToast('清除失败: ' + err.message, 'error');
}
}
// --- Quick command sender for device detail panel ---
async function _quickCmd(deviceId, cmd, btnEl) {
if (btnEl) { btnEl.disabled = true; btnEl.style.opacity = '0.5'; }
@@ -2114,6 +2253,10 @@
if (mapPolyline) { locationMap.remove(mapPolyline); mapPolyline = null; }
if (trackPlayTimer) { cancelAnimationFrame(trackPlayTimer); trackPlayTimer = null; }
if (trackMovingMarker) { locationMap.remove(trackMovingMarker); trackMovingMarker = null; }
if (_focusInfoWindow) { _focusInfoWindow.close(); _focusInfoWindow = null; }
if (_focusMarker) { locationMap.remove(_focusMarker); _focusMarker = null; }
const legend = document.getElementById('mapLegend');
if (legend) legend.style.display = 'none';
}
async function loadTrack() {
@@ -2158,13 +2301,46 @@
path.push([mLng, mLat]);
const isFirst = i === 0;
const isLast = i === total - 1;
const lt = loc.location_type || '';
const isLbs = lt.startsWith('lbs');
const isWifi = lt.startsWith('wifi');
const isBt = lt === 'bluetooth';
// Color by location type: GPS=blue, WiFi=cyan, LBS=orange, BT=purple
let dotColor = '#3b82f6';
if (isFirst) dotColor = '#22c55e';
else if (isLast) dotColor = '#ef4444';
else if (isLbs) dotColor = '#f59e0b';
else if (isWifi) dotColor = '#06b6d4';
else if (isBt) dotColor = '#a855f7';
const marker = new AMap.CircleMarker({
center: [mLng, mLat],
radius: isFirst || isLast ? 12 : 7,
fillColor: isFirst ? '#22c55e' : isLast ? '#ef4444' : '#3b82f6',
strokeColor: '#fff', strokeWeight: 1, fillOpacity: 0.9,
radius: isFirst || isLast ? 12 : 8,
fillColor: dotColor,
strokeColor: isLbs ? '#f59e0b' : '#fff',
strokeWeight: isLbs ? 2 : 1,
strokeOpacity: isLbs ? 0.6 : 1,
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 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) });
@@ -2179,9 +2355,12 @@
mapPolyline.setMap(locationMap);
locationMap.setFitView([mapPolyline], false, [50, 50, 50, 50]);
} else if (path.length === 1) {
locationMap.setZoomAndCenter(15, path[0]);
locationMap.setCenter(path[0]);
locationMap.setZoom(15);
}
const legend = document.getElementById('mapLegend');
if (legend) legend.style.display = 'block';
showToast(`已加载 ${total} 个轨迹点`);
} catch (err) {
showToast('加载轨迹失败: ' + err.message, 'error');
@@ -2192,7 +2371,15 @@
const deviceId = document.getElementById('locDeviceSelect').value;
if (!deviceId) { showToast('请选择设备', 'error'); return; }
const btn = document.querySelector('.btn-success');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 获取中...';
try {
// Record timestamp before sending command
const sentAt = new Date().toISOString();
// Send WHERE# to request fresh position from device
try {
await apiCall(`${API_BASE}/commands/send`, {
@@ -2200,32 +2387,81 @@
body: JSON.stringify({ device_id: parseInt(deviceId), command_type: 'online_cmd', command_content: 'WHERE#' }),
});
showToast('已发送定位指令,等待设备回传...', 'info');
await new Promise(r => setTimeout(r, 3000));
} catch (_) { /* device may be offline, still show DB data */ }
} catch (e) {
showToast('设备可能离线: ' + e.message, 'error');
btn.disabled = false;
btn.innerHTML = origHtml;
return;
}
// Poll for new location (up to 30s, every 3s)
let loc = null;
const maxPolls = 10;
const pollInterval = 3000;
for (let i = 0; i < maxPolls; i++) {
await new Promise(r => setTimeout(r, pollInterval));
try {
const result = await apiCall(`${API_BASE}/locations/latest/${deviceId}`);
if (result && (result.latitude != null || result.longitude != null)) {
const recTime = new Date(result.recorded_at || result.created_at);
if (recTime >= new Date(sentAt) - 5000) {
loc = result;
break;
}
}
} catch (_) {}
const elapsed = (i + 1) * pollInterval / 1000;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> 等待回传 ${elapsed}s...`;
}
if (!loc) {
showToast('设备未在30秒内回传位置LBS模式下设备响应较慢属正常现象', 'error');
return;
}
const loc = await apiCall(`${API_BASE}/locations/latest/${deviceId}`);
if (!loc) { showToast('暂无位置数据', 'info'); return; }
if (!locationMap) initLocationMap();
clearMapOverlays();
const lat = loc.latitude || loc.lat;
const lng = loc.longitude || loc.lng || loc.lon;
if (!lat || !lng) { showToast('没有有效坐标数据', 'info'); return; }
if (!lat || !lng) { showToast('设备回传了数据但无有效坐标', 'info'); return; }
const [mLat, mLng] = toMapCoord(lat, lng);
const marker = new AMap.Marker({ position: [mLng, mLat] });
marker.setMap(locationMap);
const infoContent = _buildInfoContent('最新位置', loc, lat, lng);
// 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]);
marker.on('click', () => infoWindow.open(locationMap, [mLng, mLat]));
mapMarkers.push(marker);
locationMap.setZoomAndCenter(15, [mLng, mLat]);
showToast('已显示最新位置');
// Auto-load records table
locationMap.setCenter([mLng, mLat]);
locationMap.setZoom(15);
showToast('已获取设备实时位置');
loadLocationRecords(1);
} catch (err) {
showToast('获取最新位置失败: ' + err.message, 'error');
showToast('获取位置失败: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
}
}
@@ -2248,15 +2484,17 @@
try {
const data = await apiCall(url);
const items = data.items || [];
_locTableItems = items; // cache for focusMapPoint
const tbody = document.getElementById('locationsTableBody');
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(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})"` : ''}>
<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>
<td>${l.latitude != null ? Number(l.latitude).toFixed(6) : '-'}</td>
@@ -2272,10 +2510,12 @@
</tr>`;
}).join('');
}
document.getElementById('locSelectAll').checked = false;
updateLocSelCount();
renderPagination('locationsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadLocationRecords');
} catch (err) {
showToast('加载位置记录失败: ' + err.message, 'error');
document.getElementById('locationsTableBody').innerHTML = '<tr><td colspan="9" class="text-center text-red-400 py-8">加载失败</td></tr>';
document.getElementById('locationsTableBody').innerHTML = '<tr><td colspan="10" class="text-center text-red-400 py-8">加载失败</td></tr>';
} finally {
hideLoading('locationsLoading');
}
@@ -2390,12 +2630,14 @@
const ps = pageState.attendance.pageSize;
const deviceId = document.getElementById('attDeviceFilter').value;
const attType = document.getElementById('attTypeFilter').value;
const attSource = document.getElementById('attSourceFilter').value;
const startTime = document.getElementById('attStartDate').value;
const endTime = document.getElementById('attEndDate').value;
let url = `${API_BASE}/attendance?page=${p}&page_size=${ps}`;
if (deviceId) url += `&device_id=${deviceId}`;
if (attType) url += `&attendance_type=${attType}`;
if (attSource) url += `&attendance_source=${attSource}`;
if (startTime) url += `&start_time=${startTime}T00:00:00`;
if (endTime) url += `&end_time=${endTime}T23:59:59`;
@@ -2406,7 +2648,7 @@
const tbody = document.getElementById('attendanceTableBody');
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-gray-500 py-8">没有考勤记录</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" 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)}` : '-');
@@ -2414,9 +2656,12 @@
const battStr = a.battery_level != null ? `${a.battery_level}%` : '-';
const sigStr = a.gsm_signal != null ? `GSM:${a.gsm_signal}` : '';
const lbsStr = a.mcc != null ? `${a.mcc}/${a.mnc || 0}/${a.lac || 0}/${a.cell_id || 0}` : '-';
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 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>
<td class="text-xs">${gpsIcon} ${escapeHtml(posStr)}</td>
<td class="text-xs">${battStr} ${sigStr}</td>
<td class="text-xs font-mono">${lbsStr}</td>
@@ -2427,7 +2672,7 @@
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="6" class="text-center text-red-400 py-8">加载失败</td></tr>';
document.getElementById('attendanceTableBody').innerHTML = '<tr><td colspan="7" class="text-center text-red-400 py-8">加载失败</td></tr>';
} finally {
hideLoading('attendanceLoading');
}
@@ -3222,6 +3467,96 @@
}
}
// ---- Fence Tab switching & binding tab ----
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();
}
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 (_) {}
}
}
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;
}
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('');
}
} catch (err) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-red-400 py-4">加载失败</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');
}
}
async function quickUnbindDevice(fenceId, deviceId, name) {
if (!confirm(`确定解绑设备 "${name}" ?`)) return;
try {
await apiCall(`${API_BASE}/fences/${fenceId}/devices`, {
method: 'DELETE',
body: JSON.stringify({ device_ids: [deviceId] }),
});
showToast('已解绑');
loadFenceBindingTab();
} catch (err) {
showToast('解绑失败: ' + err.message, 'error');
}
}
// ---- Beacon map picker ----
let _beaconPickerMap = null;
let _beaconPickerMarker = null;