feat: 性能优化 + 设备总览轨迹展示 + 广播指令API
性能: SQLite WAL模式、aiohttp Session复用、TCP连接锁+空闲超时、 device_id缓存、WebSocket并发广播、API Key认证缓存、围栏N+1查询 批量化、逆地理编码并行化、新增5个DB索引、日志降级DEBUG 功能: 广播指令API(broadcast)、exclude_type低精度后端过滤、 前端设备总览Tab+多设备轨迹叠加+高亮联动+搜索+专属颜色 via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
@@ -139,6 +139,17 @@
|
||||
.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; }
|
||||
@media (max-width: 768px) { .page-with-panel { flex-direction: column; height: auto; } .side-panel { width: 100%; min-width: 100%; max-height: 300px; } .side-panel.collapsed { max-height: 0; } }
|
||||
.dev-sort:hover { background: #374151; }
|
||||
.sort-arrow { font-size: 10px; color: #6b7280; margin-left: 2px; }
|
||||
.sort-arrow.asc::after { content: '▲'; color: #60a5fa; }
|
||||
.sort-arrow.desc::after { content: '▼'; color: #60a5fa; }
|
||||
.dev-qcmd { height:24px;border:1px solid #374151;border-radius:5px;background:#1f2937;color:#9ca3af;font-size:11px;cursor:pointer;padding:0 7px;margin:0 1px;transition:all 0.15s;white-space:nowrap; }
|
||||
.dev-qcmd i { margin-right:2px; }
|
||||
.dev-qcmd:hover:not(:disabled) { background:#2563eb;color:#fff;border-color:#2563eb; }
|
||||
.dev-qcmd.sent { background:#065f46;color:#34d399;border-color:#065f46; }
|
||||
.dev-qcmd-danger { color:#f87171; }
|
||||
.ov-dev-item:hover { background: #374151; }
|
||||
.dev-qcmd-danger:hover:not(:disabled) { background:#991b1b;color:#fff;border-color:#991b1b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -309,6 +320,12 @@
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary" onclick="loadDevices()"><i class="fas fa-sync-alt"></i> 刷新</button>
|
||||
<span style="width:1px;height:24px;background:#374151;margin:0 4px"></span>
|
||||
<button class="btn btn-secondary" onclick="_broadcastCmd('WHERE#','全部定位')"><i class="fas fa-crosshairs"></i> 全部定位</button>
|
||||
<button class="btn btn-secondary" onclick="_broadcastCmd('GPSON#','全部开GPS')"><i class="fas fa-satellite-dish"></i> 全部开GPS</button>
|
||||
<button class="btn btn-secondary" onclick="_broadcastCmd('MODE,1#','全部定时模式')"><i class="fas fa-clock"></i> 全部定时</button>
|
||||
<button class="btn btn-secondary" onclick="_broadcastCmd('MODE,3#','全部智能模式')"><i class="fas fa-brain"></i> 全部智能</button>
|
||||
<button class="btn btn-secondary" onclick="_showBroadcastModal()"><i class="fas fa-broadcast-tower"></i> 自定义广播</button>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="showAddDeviceModal()"><i class="fas fa-plus"></i> 添加设备</button>
|
||||
</div>
|
||||
@@ -318,18 +335,20 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IMEI</th>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>状态</th>
|
||||
<th>电量</th>
|
||||
<th>信号</th>
|
||||
<th>最后登录</th>
|
||||
<th>最后心跳</th>
|
||||
<th class="dev-sort" data-sort="imei" style="cursor:pointer;user-select:none">IMEI <span class="sort-arrow"></span></th>
|
||||
<th class="dev-sort" data-sort="name" style="cursor:pointer;user-select:none">名称 <span class="sort-arrow"></span></th>
|
||||
<th class="dev-sort" data-sort="device_type" style="cursor:pointer;user-select:none">类型 <span class="sort-arrow"></span></th>
|
||||
<th class="dev-sort" data-sort="status" style="cursor:pointer;user-select:none">状态 <span class="sort-arrow"></span></th>
|
||||
<th>定位模式</th>
|
||||
<th class="dev-sort" data-sort="battery_level" style="cursor:pointer;user-select:none">电量 <span class="sort-arrow"></span></th>
|
||||
<th class="dev-sort" data-sort="gsm_signal" style="cursor:pointer;user-select:none">信号 <span class="sort-arrow"></span></th>
|
||||
<th class="dev-sort" data-sort="last_login" style="cursor:pointer;user-select:none">最后登录 <span class="sort-arrow"></span></th>
|
||||
<th class="dev-sort" data-sort="last_heartbeat" style="cursor:pointer;user-select:none">最后心跳 <span class="sort-arrow"></span></th>
|
||||
<th style="text-align:center">快捷操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="devicesTableBody">
|
||||
<tr><td colspan="8" 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>
|
||||
@@ -339,6 +358,14 @@
|
||||
|
||||
<!-- ==================== LOCATIONS PAGE ==================== -->
|
||||
<div id="page-locations" class="page">
|
||||
<!-- Tab bar -->
|
||||
<div style="display:flex;gap:0;margin-bottom:12px;border-bottom:2px solid #374151">
|
||||
<button class="fence-tab active" id="locTabTrack" onclick="_switchLocTab('track')"><i class="fas fa-route mr-1"></i>轨迹追踪</button>
|
||||
<button class="fence-tab" id="locTabOverview" onclick="_switchLocTab('overview')"><i class="fas fa-users mr-1"></i>全部设备总览</button>
|
||||
</div>
|
||||
|
||||
<!-- Track tab content -->
|
||||
<div id="locTabTrackContent">
|
||||
<div class="page-guide" onclick="this.classList.toggle('collapsed')">
|
||||
<div class="guide-header">
|
||||
<div class="guide-icon"><i class="fas fa-lightbulb text-white text-sm"></i></div>
|
||||
@@ -412,7 +439,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:32px"><input type="checkbox" id="locSelectAll" onchange="toggleAllLocCheckboxes(this.checked)" title="全选当前页"></th>
|
||||
<th>设备ID</th>
|
||||
<th>IMEI</th>
|
||||
<th>类型</th>
|
||||
<th>纬度</th>
|
||||
<th>经度</th>
|
||||
@@ -432,6 +459,45 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /locTabTrackContent -->
|
||||
|
||||
<!-- Overview tab content -->
|
||||
<div id="locTabOverviewContent" style="display:none">
|
||||
<div style="display:flex;gap:12px;height:calc(100vh - 200px);min-height:500px">
|
||||
<!-- Left: device checklist -->
|
||||
<div style="width:260px;min-width:260px;background:#1f2937;border:1px solid #374151;border-radius:10px;display:flex;flex-direction:column;overflow:hidden">
|
||||
<div style="padding:10px 12px;border-bottom:1px solid #374151;font-size:13px;font-weight:600;color:#d1d5db;display:flex;align-items:center;gap:6px">
|
||||
<i class="fas fa-users text-blue-400"></i> 设备选择
|
||||
<span id="ovSelectedCount" style="margin-left:auto;font-size:11px;color:#6b7280"></span>
|
||||
</div>
|
||||
<div style="padding:6px 8px;border-bottom:1px solid #374151;display:flex;flex-direction:column;gap:4px">
|
||||
<div style="position:relative">
|
||||
<i class="fas fa-search" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);color:#6b7280;font-size:11px"></i>
|
||||
<input type="text" id="ovDeviceSearch" placeholder="搜索名称/IMEI..." oninput="_ovFilterDevices()" style="width:100%;padding:4px 8px 4px 26px;font-size:11px;background:#111827;border:1px solid #374151;border-radius:5px;color:#d1d5db">
|
||||
</div>
|
||||
<label style="font-size:11px;color:#9ca3af;cursor:pointer;display:flex;align-items:center;gap:6px">
|
||||
<input type="checkbox" id="ovSelectAll" checked onchange="_ovToggleAll(this.checked)"> 全选/取消
|
||||
</label>
|
||||
</div>
|
||||
<div id="ovDeviceList" style="flex:1;overflow-y:auto;padding:4px 8px"></div>
|
||||
</div>
|
||||
<!-- Right: map + toolbar -->
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:8px">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button class="btn btn-primary" onclick="loadAllDevicePositions()"><i class="fas fa-sync-alt"></i> 刷新位置</button>
|
||||
<button class="btn btn-success" onclick="_ovRequestAllPositions()"><i class="fas fa-satellite-dish"></i> 获取实时位置</button>
|
||||
<span style="width:1px;height:24px;background:#374151"></span>
|
||||
<button class="btn btn-secondary" onclick="_ovShowTrack()" id="ovBtnTrack"><i class="fas fa-route"></i> 显示轨迹</button>
|
||||
<button class="btn btn-secondary" onclick="_ovClearTrack()" id="ovBtnClearTrack" style="display:none"><i class="fas fa-times"></i> 清除轨迹</button>
|
||||
<button id="ovBtnHideLP" class="btn btn-secondary" onclick="_ovToggleLP()"><i class="fas fa-eye"></i> 低精度</button>
|
||||
<span id="overviewDeviceCount" class="text-sm text-gray-400"></span>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden" style="flex:1;position:relative">
|
||||
<div id="overviewMap" style="height:100%;width:100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== ALARMS PAGE ==================== -->
|
||||
@@ -505,7 +571,7 @@
|
||||
<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>IMEI</th>
|
||||
<th>类型</th>
|
||||
<th>来源</th>
|
||||
<th>位置</th>
|
||||
@@ -589,7 +655,7 @@
|
||||
<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>IMEI</th>
|
||||
<th>类型</th>
|
||||
<th>来源</th>
|
||||
<th>位置</th>
|
||||
@@ -657,7 +723,7 @@
|
||||
<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>IMEI</th>
|
||||
<th>类型</th>
|
||||
<th>信标MAC</th>
|
||||
<th>UUID / Major / Minor</th>
|
||||
@@ -888,7 +954,6 @@
|
||||
<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>
|
||||
<th>IMEI</th>
|
||||
<th>详情</th>
|
||||
<th>坐标</th>
|
||||
@@ -897,7 +962,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="datalogTableBody">
|
||||
<tr><td colspan="9" 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>
|
||||
@@ -1432,12 +1497,20 @@
|
||||
|
||||
// ==================== DEVICE SELECTOR HELPER ====================
|
||||
let cachedDevices = null;
|
||||
let _devIdToImei = {}; // {device_id: imei} global mapping
|
||||
|
||||
function _imei(deviceId) {
|
||||
return _devIdToImei[deviceId] || deviceId || '-';
|
||||
}
|
||||
|
||||
async function loadDeviceSelectors() {
|
||||
try {
|
||||
const data = await apiCall(`${API_BASE}/devices?page=1&page_size=100`);
|
||||
const devices = data.items || [];
|
||||
cachedDevices = devices;
|
||||
// Build global device_id -> imei mapping
|
||||
_devIdToImei = {};
|
||||
devices.forEach(d => { _devIdToImei[d.id || d.device_id] = d.imei; });
|
||||
const selectors = ['locDeviceSelect', 'alarmDeviceFilter', 'attDeviceFilter', 'btDeviceFilter', 'cmdHistoryDeviceFilter'];
|
||||
selectors.forEach(id => {
|
||||
const sel = document.getElementById(id);
|
||||
@@ -1567,7 +1640,7 @@
|
||||
<i class="fas fa-exclamation-circle ${alarmTypeClass(a.alarm_type)}"></i>
|
||||
<div>
|
||||
<span class="text-sm font-medium ${alarmTypeClass(a.alarm_type)}">${alarmTypeName(a.alarm_type)}</span>
|
||||
<span class="text-xs text-gray-500 ml-2">设备: ${escapeHtml(a.device_id || '-')}</span>
|
||||
<span class="text-xs text-gray-500 ml-2">IMEI: ${escapeHtml(_imei(a.device_id))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -1650,6 +1723,75 @@
|
||||
}
|
||||
|
||||
// ==================== DEVICES ====================
|
||||
let _devItems = [];
|
||||
let _devLocModes = {};
|
||||
const _devSort = { field: 'name', dir: 'asc' };
|
||||
|
||||
function _renderDeviceRows() {
|
||||
const tbody = document.getElementById('devicesTableBody');
|
||||
if (!_devItems.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-gray-500 py-8">没有找到设备</td></tr>';
|
||||
return;
|
||||
}
|
||||
const sorted = [..._devItems].sort((a, b) => {
|
||||
const f = _devSort.field;
|
||||
let va = a[f], vb = b[f];
|
||||
if (va == null) va = '';
|
||||
if (vb == null) vb = '';
|
||||
if (typeof va === 'number' && typeof vb === 'number') return _devSort.dir === 'asc' ? va - vb : vb - va;
|
||||
va = String(va); vb = String(vb);
|
||||
const cmp = va.localeCompare(vb, 'zh-CN', { numeric: true });
|
||||
return _devSort.dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
tbody.innerHTML = sorted.map(d => {
|
||||
const did = d.id || d.device_id || '';
|
||||
const on = d.status === 'online';
|
||||
const dis = on ? '' : 'disabled style="opacity:0.35;cursor:not-allowed"';
|
||||
return `
|
||||
<tr>
|
||||
<td class="font-mono text-sm" style="cursor:pointer;color:#60a5fa" onclick="showDeviceDetail('${did}')">${escapeHtml(d.imei)}</td>
|
||||
<td style="cursor:pointer" onclick="showDeviceDetail('${did}')">${escapeHtml(d.name || '-')}</td>
|
||||
<td>${escapeHtml(d.device_type || '-')}</td>
|
||||
<td>${statusBadge(d.status)}</td>
|
||||
<td class="text-xs">${_locTypeBadge(_devLocModes[d.id] || null)}</td>
|
||||
<td>${d.battery_level !== undefined && d.battery_level !== null ? `<span class="${d.battery_level < 20 ? 'text-red-400' : 'text-green-400'}">${d.battery_level}%</span>` : '-'}</td>
|
||||
<td>${d.gsm_signal !== undefined && d.gsm_signal !== null ? d.gsm_signal : '-'}</td>
|
||||
<td class="text-xs text-gray-400">${formatTime(d.last_login)}</td>
|
||||
<td class="text-xs text-gray-400">${formatTime(d.last_heartbeat)}</td>
|
||||
<td style="text-align:center;white-space:nowrap" onclick="event.stopPropagation()">
|
||||
<button class="dev-qcmd" ${dis} onclick="_devQuickCmd('${did}','WHERE#',this)"><i class="fas fa-crosshairs"></i> 定位</button>
|
||||
<button class="dev-qcmd" ${dis} onclick="_devQuickCmd('${did}','GPSON#',this)"><i class="fas fa-satellite-dish"></i> GPS</button>
|
||||
<button class="dev-qcmd" ${dis} onclick="_devQuickCmd('${did}','MODE,1#',this)"><i class="fas fa-clock"></i> 定时</button>
|
||||
<button class="dev-qcmd" ${dis} onclick="_devQuickCmd('${did}','STATUS#',this)"><i class="fas fa-info-circle"></i> 状态</button>
|
||||
<button class="dev-qcmd dev-qcmd-danger" ${dis} onclick="if(confirm('确定重启该设备?'))_devQuickCmd('${did}','RESET#',this)"><i class="fas fa-power-off"></i> 重启</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _updateSortArrows() {
|
||||
document.querySelectorAll('.dev-sort .sort-arrow').forEach(el => {
|
||||
el.className = 'sort-arrow';
|
||||
if (el.parentElement.dataset.sort === _devSort.field) {
|
||||
el.classList.add(_devSort.dir);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const th = e.target.closest('.dev-sort');
|
||||
if (!th) return;
|
||||
const field = th.dataset.sort;
|
||||
if (_devSort.field === field) {
|
||||
_devSort.dir = _devSort.dir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
_devSort.field = field;
|
||||
_devSort.dir = 'asc';
|
||||
}
|
||||
_updateSortArrows();
|
||||
_renderDeviceRows();
|
||||
});
|
||||
|
||||
async function loadDevices(page) {
|
||||
if (page) pageState.devices.page = page;
|
||||
const p = pageState.devices.page;
|
||||
@@ -1664,29 +1806,18 @@
|
||||
showLoading('devicesLoading');
|
||||
try {
|
||||
const data = await apiCall(url);
|
||||
const items = data.items || [];
|
||||
const tbody = document.getElementById('devicesTableBody');
|
||||
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-500 py-8">没有找到设备</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = items.map(d => `
|
||||
<tr style="cursor:pointer" onclick="showDeviceDetail('${d.id || d.device_id || ''}')">
|
||||
<td class="font-mono text-sm">${escapeHtml(d.imei)}</td>
|
||||
<td>${escapeHtml(d.name || '-')}</td>
|
||||
<td>${escapeHtml(d.device_type || '-')}</td>
|
||||
<td>${statusBadge(d.status)}</td>
|
||||
<td>${d.battery_level !== undefined && d.battery_level !== null ? `<span class="${d.battery_level < 20 ? 'text-red-400' : 'text-green-400'}">${d.battery_level}%</span>` : '-'}</td>
|
||||
<td>${d.gsm_signal !== undefined && d.gsm_signal !== null ? d.gsm_signal : '-'}</td>
|
||||
<td class="text-xs text-gray-400">${formatTime(d.last_login)}</td>
|
||||
<td class="text-xs text-gray-400">${formatTime(d.last_heartbeat)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
_devItems = data.items || [];
|
||||
_devLocModes = {};
|
||||
_renderDeviceRows();
|
||||
_updateSortArrows();
|
||||
renderPagination('devicesPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadDevices');
|
||||
// Fetch latest location types asynchronously
|
||||
if (_devItems.length) {
|
||||
_fillDeviceLocModes(_devItems.map(d => d.id));
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('加载设备列表失败: ' + err.message, 'error');
|
||||
document.getElementById('devicesTableBody').innerHTML = '<tr><td colspan="8" class="text-center text-red-400 py-8">加载失败</td></tr>';
|
||||
document.getElementById('devicesTableBody').innerHTML = '<tr><td colspan="10" class="text-center text-red-400 py-8">加载失败</td></tr>';
|
||||
} finally {
|
||||
hideLoading('devicesLoading');
|
||||
}
|
||||
@@ -1736,6 +1867,25 @@
|
||||
const map = { gps: 'GPS', gps_4g: 'GPS 4G', lbs: 'LBS 基站', lbs_4g: 'LBS 4G', wifi: 'WiFi', wifi_4g: 'WiFi 4G', bluetooth: '蓝牙' };
|
||||
return map[t] || t || '-';
|
||||
}
|
||||
function _locTypeBadge(t) {
|
||||
if (!t) return '<span class="text-gray-600">-</span>';
|
||||
const colors = { gps: '#3b82f6', gps_4g: '#3b82f6', wifi: '#06b6d4', wifi_4g: '#06b6d4', lbs: '#f59e0b', lbs_4g: '#f59e0b', bluetooth: '#a855f7' };
|
||||
const color = colors[t] || '#6b7280';
|
||||
const label = _locTypeLabel(t);
|
||||
return `<span style="color:${color};font-weight:600">${label}</span>`;
|
||||
}
|
||||
async function _fillDeviceLocModes(deviceIds) {
|
||||
if (!deviceIds.length) return;
|
||||
try {
|
||||
const data = await apiCall(`${API_BASE}/locations/batch-latest`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ device_ids: deviceIds })
|
||||
});
|
||||
const locs = Array.isArray(data) ? data : [];
|
||||
locs.forEach(l => { if (l) _devLocModes[l.device_id] = l.location_type; });
|
||||
_renderDeviceRows();
|
||||
} catch (e) { /* silent - non-critical */ }
|
||||
}
|
||||
function _locModeBadges(locType) {
|
||||
const modes = [
|
||||
{ label: 'GPS', match: ['gps','gps_4g'] },
|
||||
@@ -1872,6 +2022,8 @@
|
||||
const content = _buildInfoContent('位置记录', loc, lat, lng);
|
||||
_focusInfoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -30) });
|
||||
_focusInfoWindow.open(locationMap, [mLng, mLat]);
|
||||
_focusMarker.on('mouseover', () => _focusInfoWindow.open(locationMap, [mLng, mLat]));
|
||||
_focusMarker.on('mouseout', () => _focusInfoWindow.close());
|
||||
_focusMarker.on('click', () => _focusInfoWindow.open(locationMap, [mLng, mLat]));
|
||||
|
||||
locationMap.setCenter([mLng, mLat]);
|
||||
@@ -1977,6 +2129,86 @@
|
||||
}
|
||||
|
||||
// --- Quick command sender for device detail panel ---
|
||||
async function _devQuickCmd(deviceId, cmd, btnEl) {
|
||||
if (btnEl) { btnEl.disabled = true; btnEl.classList.add('sent'); }
|
||||
try {
|
||||
const res = await apiCall(`${API_BASE}/commands/send`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ device_id: parseInt(deviceId), command_type: 'online_cmd', command_content: cmd }),
|
||||
});
|
||||
showToast(`${cmd} → 已发送`);
|
||||
// Poll for response
|
||||
const cmdId = res && res.id;
|
||||
if (cmdId) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
try {
|
||||
const c = await apiCall(`${API_BASE}/commands/${cmdId}`);
|
||||
if (c.response_content) { showToast(`${cmd} → ${c.response_content}`); break; }
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`${cmd} 发送失败: ${err.message}`, 'error');
|
||||
} finally {
|
||||
if (btnEl) { btnEl.disabled = false; btnEl.classList.remove('sent'); }
|
||||
}
|
||||
}
|
||||
|
||||
async function _broadcastCmd(cmd, label) {
|
||||
if (!confirm(`确定向所有设备发送 ${cmd} ?`)) return;
|
||||
try {
|
||||
const data = await apiCall(`${API_BASE}/commands/broadcast`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ command_type: 'online_cmd', command_content: cmd }),
|
||||
});
|
||||
showToast(`${label}: ${data.sent} 台已发送, ${data.failed} 台未连接`);
|
||||
} catch (err) {
|
||||
showToast(`${label} 失败: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function _showBroadcastModal() {
|
||||
showModal(`
|
||||
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-broadcast-tower mr-2 text-yellow-400"></i>广播指令 — 发送给所有在线设备</h3>
|
||||
<div class="form-group">
|
||||
<label>指令内容</label>
|
||||
<input type="text" id="broadcastCmdInput" placeholder="如: GPSON#, MODE,1#, TIMER,60#" maxlength="500">
|
||||
<p class="text-xs text-gray-500 mt-1">将发送给所有在线设备,离线设备自动跳过</p>
|
||||
</div>
|
||||
<div style="margin-top:8px">
|
||||
<div style="font-size:11px;color:#6b7280;margin-bottom:6px">常用指令快捷选择:</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px">
|
||||
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='GPSON#'" title="开启GPS定位模块,持续5分钟">GPSON# 开启GPS</button>
|
||||
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='GPSOFF#'" title="关闭GPS定位模块,省电">GPSOFF# 关闭GPS</button>
|
||||
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='MODE,1#'" title="定时上报位置,按TIMER间隔">MODE,1# 定时定位</button>
|
||||
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='MODE,3#'" title="智能模式:运动时高频上报,静止时低频">MODE,3# 智能模式</button>
|
||||
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='WHERE#'" title="立即获取一次设备当前位置">WHERE# 立即定位</button>
|
||||
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='STATUS#'" title="查询电量、信号、GPS状态等">STATUS# 设备状态</button>
|
||||
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='TIMER,60#'" title="每60秒上报一次位置">TIMER,60# 间隔1分钟</button>
|
||||
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='TIMER,300#'" title="每300秒上报一次位置">TIMER,300# 间隔5分钟</button>
|
||||
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='BTON#'" title="开启蓝牙模块">BTON# 开蓝牙</button>
|
||||
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='BTSCAN,1#'" title="开启BLE信标扫描">BTSCAN,1# 开BLE扫描</button>
|
||||
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='PARAM#'" title="查询设备当前所有参数配置">PARAM# 参数查询</button>
|
||||
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='VERSION#'" title="查询设备固件版本号">VERSION# 固件版本</button>
|
||||
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='RESET#'" style="color:#f87171" title="远程重启设备,慎用">RESET# 重启设备</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button class="btn btn-primary flex-1" onclick="_doBroadcast()"><i class="fas fa-paper-plane"></i> 发送广播</button>
|
||||
<button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 取消</button>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
async function _doBroadcast() {
|
||||
const cmd = document.getElementById('broadcastCmdInput').value.trim();
|
||||
if (!cmd) { showToast('请输入指令内容', 'error'); return; }
|
||||
if (!confirm('确定向所有设备发送 ' + cmd + ' ?')) return;
|
||||
closeModal();
|
||||
await _broadcastCmd(cmd, '广播 ' + cmd);
|
||||
}
|
||||
|
||||
async function _quickCmd(deviceId, cmd, btnEl) {
|
||||
if (btnEl) { btnEl.disabled = true; btnEl.style.opacity = '0.5'; }
|
||||
try {
|
||||
@@ -2260,6 +2492,381 @@
|
||||
return wgs84ToGcj02(lat, lng);
|
||||
}
|
||||
|
||||
// ==================== LOCATION TAB SWITCH ====================
|
||||
let _ovInited = false;
|
||||
async function _switchLocTab(tab) {
|
||||
document.getElementById('locTabTrack').classList.toggle('active', tab === 'track');
|
||||
document.getElementById('locTabOverview').classList.toggle('active', tab === 'overview');
|
||||
document.getElementById('locTabTrackContent').style.display = tab === 'track' ? '' : 'none';
|
||||
document.getElementById('locTabOverviewContent').style.display = tab === 'overview' ? '' : 'none';
|
||||
if (tab === 'overview') {
|
||||
if (!_overviewMap) _initOverviewMap();
|
||||
if (!cachedDevices || !cachedDevices.length) await loadDeviceSelectors();
|
||||
if (!_ovInited) {
|
||||
_ovInited = true;
|
||||
cachedDevices.forEach(d => _ovSelectedDevices.add(d.id));
|
||||
}
|
||||
_ovBuildDeviceList();
|
||||
loadAllDevicePositions();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== OVERVIEW MAP ====================
|
||||
let _overviewMap = null;
|
||||
let _ovMarkerMap = {}; // {device_id: marker}
|
||||
let _ovSelectedDevices = new Set(); // selected device IDs
|
||||
|
||||
function _initOverviewMap() {
|
||||
if (_overviewMap) return;
|
||||
setTimeout(() => {
|
||||
const [mLat, mLng] = toMapCoord(30.605, 103.936);
|
||||
_overviewMap = new AMap.Map('overviewMap', {
|
||||
viewMode: '3D', pitch: 45, rotation: -15, rotateEnable: true,
|
||||
zoom: 14, center: [mLng, mLat],
|
||||
mapStyle: 'amap://styles/normal',
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function _clearOverviewMarkers() {
|
||||
Object.values(_ovMarkerMap).forEach(m => m.setMap(null));
|
||||
_ovMarkerMap = {};
|
||||
}
|
||||
|
||||
let _ovPinnedIW = null; // pinned (click) info window
|
||||
|
||||
function _ovLocateDevice(did) {
|
||||
// Highlight in list
|
||||
document.querySelectorAll('.ov-dev-item').forEach(el => {
|
||||
el.style.background = Number(el.dataset.did) === did ? '#1e3a5f' : '';
|
||||
});
|
||||
// Move map to device marker (show even if unchecked)
|
||||
const marker = _ovMarkerMap[did];
|
||||
if (marker) {
|
||||
if (!marker.getMap()) marker.setMap(_overviewMap);
|
||||
const pos = marker.getPosition();
|
||||
_overviewMap.setCenter(pos);
|
||||
_overviewMap.setZoom(16);
|
||||
if (_ovPinnedIW) _ovPinnedIW.close();
|
||||
marker.emit('click', { lnglat: pos });
|
||||
} else {
|
||||
showToast('该设备暂无位置数据', 'info');
|
||||
}
|
||||
// Highlight this device's track if exists
|
||||
_ovHighlightByDevice(did);
|
||||
}
|
||||
|
||||
function _ovSyncMarkerVisibility() {
|
||||
let visibleCount = 0;
|
||||
const visible = [];
|
||||
for (const [did, marker] of Object.entries(_ovMarkerMap)) {
|
||||
if (_ovSelectedDevices.has(Number(did))) {
|
||||
marker.setMap(_overviewMap);
|
||||
visible.push(marker);
|
||||
visibleCount++;
|
||||
} else {
|
||||
marker.setMap(null);
|
||||
}
|
||||
}
|
||||
document.getElementById('overviewDeviceCount').textContent =
|
||||
`已选 ${_ovSelectedDevices.size} 台,${visibleCount} 台有位置`;
|
||||
}
|
||||
|
||||
function _ovBuildDeviceList() {
|
||||
const list = document.getElementById('ovDeviceList');
|
||||
if (!cachedDevices || !cachedDevices.length) {
|
||||
list.innerHTML = '<div style="padding:12px;color:#6b7280;font-size:12px;text-align:center">无设备</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = cachedDevices.map(d => {
|
||||
const checked = _ovSelectedDevices.has(d.id) ? 'checked' : '';
|
||||
const statusDot = d.status === 'online' ? '🟢' : '⚫';
|
||||
return `<div style="display:flex;align-items:center;gap:4px;padding:5px 4px;color:#d1d5db;border-bottom:1px solid #374151;overflow:hidden" class="ov-dev-item" data-did="${d.id}">
|
||||
<input type="checkbox" ${checked} onchange="event.stopPropagation();_ovToggleDevice(${d.id},this.checked)" style="flex:0 0 14px;width:14px;height:14px;cursor:pointer">
|
||||
<span style="flex:0 0 16px;font-size:12px">${statusDot}</span>
|
||||
<span style="flex:1;min-width:0;overflow:hidden;cursor:pointer" onclick="_ovLocateDevice(${d.id})">
|
||||
<span style="display:block;font-size:12px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(d.name || '-')}</span>
|
||||
<span style="display:block;font-size:10px;color:#6b7280;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(d.imei)}</span>
|
||||
</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
_ovUpdateCount();
|
||||
document.getElementById('ovSelectAll').checked = _ovSelectedDevices.size === cachedDevices.length;
|
||||
}
|
||||
|
||||
function _ovToggleDevice(id, checked) {
|
||||
if (checked) _ovSelectedDevices.add(id); else _ovSelectedDevices.delete(id);
|
||||
_ovUpdateCount();
|
||||
document.getElementById('ovSelectAll').checked = _ovSelectedDevices.size === (cachedDevices || []).length;
|
||||
_ovSyncMarkerVisibility();
|
||||
}
|
||||
|
||||
function _ovFilterDevices() {
|
||||
const q = (document.getElementById('ovDeviceSearch').value || '').toLowerCase();
|
||||
document.querySelectorAll('.ov-dev-item').forEach(el => {
|
||||
const text = el.textContent.toLowerCase();
|
||||
el.style.display = !q || text.includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function _ovToggleAll(checked) {
|
||||
(cachedDevices || []).forEach(d => {
|
||||
if (checked) _ovSelectedDevices.add(d.id); else _ovSelectedDevices.delete(d.id);
|
||||
});
|
||||
_ovBuildDeviceList();
|
||||
_ovSyncMarkerVisibility();
|
||||
}
|
||||
|
||||
function _ovUpdateCount() {
|
||||
const el = document.getElementById('ovSelectedCount');
|
||||
if (el) el.textContent = `${_ovSelectedDevices.size}/${(cachedDevices||[]).length}`;
|
||||
}
|
||||
|
||||
async function loadAllDevicePositions() {
|
||||
if (!_overviewMap) { _initOverviewMap(); await new Promise(r => setTimeout(r, 200)); }
|
||||
if (!cachedDevices || !cachedDevices.length) await loadDeviceSelectors();
|
||||
_clearOverviewMarkers();
|
||||
|
||||
const allIds = cachedDevices.map(d => d.id);
|
||||
if (!allIds.length) return;
|
||||
|
||||
try {
|
||||
const data = await apiCall(`${API_BASE}/locations/batch-latest`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ device_ids: allIds }),
|
||||
});
|
||||
const locs = Array.isArray(data) ? data : [];
|
||||
const _devColors = ['#3b82f6','#ef4444','#22c55e','#f59e0b','#a855f7','#06b6d4','#ec4899','#84cc16','#f97316','#6366f1','#14b8a6','#e879f9','#facc15','#fb923c'];
|
||||
const _devColorMap = {};
|
||||
allIds.forEach((id, idx) => { _devColorMap[id] = _devColors[idx % _devColors.length]; });
|
||||
let plotted = 0;
|
||||
locs.forEach((loc, i) => {
|
||||
if (!loc) return;
|
||||
const lat = loc.latitude, lng = loc.longitude;
|
||||
if (!lat || !lng) return;
|
||||
const did = allIds[i];
|
||||
const [mLat, mLng] = toMapCoord(lat, lng);
|
||||
const dev = cachedDevices.find(d => d.id === did);
|
||||
const devName = dev ? (dev.name || '') : '';
|
||||
const devImei = dev ? dev.imei : _imei(did);
|
||||
const isOnline = dev && dev.status === 'online';
|
||||
const color = _devColorMap[did] || '#3b82f6';
|
||||
const borderColor = isOnline ? '#22c55e' : '#6b7280';
|
||||
const labelText = devName ? `${devName} (${devImei.slice(-4)})` : devImei;
|
||||
|
||||
const marker = new AMap.Marker({
|
||||
position: [mLng, mLat],
|
||||
label: { content: `<span style="background:${color};color:#fff;padding:2px 8px;border-radius:8px;font-size:11px;white-space:nowrap;border:2px solid ${borderColor}">${escapeHtml(labelText)}</span>`, direction: 'top', offset: new AMap.Pixel(0, -5) },
|
||||
});
|
||||
// Don't add to map yet — visibility controlled by _ovSyncMarkerVisibility
|
||||
|
||||
const statusText = isOnline ? '<span style="color:#22c55e">● 在线</span>' : '<span style="color:#6b7280">● 离线</span>';
|
||||
const title = `${escapeHtml(devName || devImei)} ${statusText}<br><span style="font-size:11px;color:#9ca3af;font-family:monospace">${escapeHtml(devImei)}</span>`;
|
||||
const content = _buildInfoContent(title, loc, lat, lng);
|
||||
// Hover: lightweight preview
|
||||
const hoverIW = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -30) });
|
||||
let _pinned = false;
|
||||
marker.on('mouseover', () => { if (!_pinned) hoverIW.open(_overviewMap, [mLng, mLat]); });
|
||||
marker.on('mouseout', () => { if (!_pinned) hoverIW.close(); });
|
||||
// Click: pin the info window + highlight track
|
||||
const _clickDid = did;
|
||||
marker.on('click', () => {
|
||||
if (_ovPinnedIW) _ovPinnedIW.close();
|
||||
_pinned = true;
|
||||
const pinnedIW = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -30) });
|
||||
pinnedIW.open(_overviewMap, [mLng, mLat]);
|
||||
pinnedIW.on('close', () => { _pinned = false; });
|
||||
_ovPinnedIW = pinnedIW;
|
||||
_ovHighlightByDevice(_clickDid);
|
||||
});
|
||||
_ovMarkerMap[did] = marker;
|
||||
plotted++;
|
||||
});
|
||||
|
||||
// Show only selected devices
|
||||
_ovSyncMarkerVisibility();
|
||||
|
||||
// Fit view to visible markers
|
||||
const visible = Object.entries(_ovMarkerMap)
|
||||
.filter(([did]) => _ovSelectedDevices.has(Number(did)))
|
||||
.map(([, m]) => m);
|
||||
if (visible.length > 1) {
|
||||
_overviewMap.setFitView(visible, false, [80,80,80,80]);
|
||||
} else if (visible.length === 1) {
|
||||
_overviewMap.setCenter(visible[0].getPosition());
|
||||
_overviewMap.setZoom(15);
|
||||
}
|
||||
showToast(`已加载 ${plotted} 台设备位置`);
|
||||
} catch (err) {
|
||||
showToast('加载设备位置失败: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function _ovRequestAllPositions() {
|
||||
if (!confirm('向所有设备发送定位指令?')) return;
|
||||
try {
|
||||
const data = await apiCall(`${API_BASE}/commands/broadcast`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ command_type: 'online_cmd', command_content: 'WHERE#' }),
|
||||
});
|
||||
showToast(`已发送定位指令: ${data.sent} 台已发送, ${data.failed} 台未连接,等待回传后点"刷新位置"`);
|
||||
} catch (err) {
|
||||
showToast('发送失败: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== OVERVIEW TRACK + LP FILTER ====================
|
||||
let _ovTrackMarkers = [];
|
||||
let _ovHideLP = false;
|
||||
let _ovHighlightedPL = null;
|
||||
let _ovDidToPL = {}; // {device_id: polyline} for highlight lookup
|
||||
|
||||
function _ovHighlightByDevice(did) {
|
||||
const pl = _ovDidToPL[did];
|
||||
if (pl) _ovHighlightTrack(pl);
|
||||
}
|
||||
|
||||
function _ovHighlightTrack(pl) {
|
||||
// Reset previous highlight
|
||||
_ovTrackMarkers.forEach(m => {
|
||||
if (m._ovColor) {
|
||||
m.setOptions({ strokeWeight: 3, strokeOpacity: 0.6, zIndex: 50 });
|
||||
}
|
||||
// Dim track points of other devices
|
||||
if (m.setRadius && m._ovOwnerPL && m._ovOwnerPL !== pl) {
|
||||
m.setMap(null); m._ovDimmed = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (_ovHighlightedPL === pl) {
|
||||
// Toggle off: restore all
|
||||
_ovHighlightedPL = null;
|
||||
_ovTrackMarkers.forEach(m => {
|
||||
if (m._ovColor) m.setOptions({ strokeOpacity: 0.6 });
|
||||
if (m._ovDimmed) { m.setMap(_overviewMap); m._ovDimmed = false; }
|
||||
});
|
||||
showToast('已取消高亮');
|
||||
return;
|
||||
}
|
||||
|
||||
// Highlight clicked polyline
|
||||
pl.setOptions({ strokeWeight: 6, strokeOpacity: 1, zIndex: 200 });
|
||||
_ovHighlightedPL = pl;
|
||||
// Show only this device's track points
|
||||
_ovTrackMarkers.forEach(m => {
|
||||
if (m._ovDimmed) { m.setMap(_overviewMap); m._ovDimmed = false; }
|
||||
});
|
||||
showToast(`已高亮: ${pl._ovDevName}`);
|
||||
}
|
||||
|
||||
async function _ovShowTrack() {
|
||||
const ids = [..._ovSelectedDevices];
|
||||
if (!ids.length) { showToast('请勾选至少一台设备', 'error'); return; }
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
_ovClearTrack();
|
||||
let totalPoints = 0;
|
||||
|
||||
// Distinct track colors per device
|
||||
const trackColors = ['#3b82f6','#ef4444','#22c55e','#f59e0b','#a855f7','#06b6d4','#ec4899','#84cc16','#f97316','#6366f1','#14b8a6'];
|
||||
|
||||
for (let idx = 0; idx < ids.length; idx++) {
|
||||
const did = ids[idx];
|
||||
const dev = cachedDevices.find(d => d.id === did);
|
||||
const devName = dev ? (dev.name || dev.imei) : did;
|
||||
const color = trackColors[idx % trackColors.length];
|
||||
|
||||
try {
|
||||
const data = await apiCall(`${API_BASE}/locations/track/${did}?start_time=${today}T00:00:00&end_time=${today}T23:59:59`);
|
||||
const locs = Array.isArray(data) ? data : (data.items || []);
|
||||
if (!locs.length) continue;
|
||||
|
||||
const path = [];
|
||||
const deviceMarkers = [];
|
||||
locs.forEach((loc, i) => {
|
||||
const lat = loc.latitude, lng = loc.longitude;
|
||||
if (!lat || !lng) return;
|
||||
const lt = (loc.location_type || '').toLowerCase();
|
||||
if (_ovHideLP && lt.startsWith('lbs')) return;
|
||||
|
||||
const [mLat, mLng] = toMapCoord(lat, lng);
|
||||
path.push([mLng, mLat]);
|
||||
|
||||
const isFirst = i === 0, isLast = i === locs.length - 1;
|
||||
const marker = new AMap.CircleMarker({
|
||||
center: [mLng, mLat],
|
||||
radius: isFirst || isLast ? 10 : 5,
|
||||
fillColor: isFirst ? '#22c55e' : isLast ? '#ef4444' : color,
|
||||
strokeColor: '#fff', strokeWeight: 1,
|
||||
fillOpacity: 0.9, zIndex: 130, cursor: 'pointer',
|
||||
});
|
||||
marker.setMap(_overviewMap);
|
||||
|
||||
const label = `${devName} ${isFirst ? '起点' : isLast ? '终点' : `第${i+1}/${locs.length}点`}`;
|
||||
const content = _buildInfoContent(label, loc, lat, lng);
|
||||
const iw = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -5) });
|
||||
let pinned = false;
|
||||
marker.on('mouseover', () => { if (!pinned) iw.open(_overviewMap, [mLng, mLat]); });
|
||||
marker.on('mouseout', () => { if (!pinned) iw.close(); });
|
||||
marker.on('click', () => { pinned = !pinned; iw.open(_overviewMap, [mLng, mLat]); });
|
||||
iw.on('close', () => { pinned = false; });
|
||||
deviceMarkers.push(marker);
|
||||
_ovTrackMarkers.push(marker);
|
||||
});
|
||||
|
||||
if (path.length > 1) {
|
||||
const pl = new AMap.Polyline({ path, strokeColor: color, strokeWeight: 3, strokeOpacity: 0.6, lineJoin: 'round', cursor: 'pointer', zIndex: 50 });
|
||||
pl.setMap(_overviewMap);
|
||||
pl._ovColor = color;
|
||||
pl._ovDevName = devName;
|
||||
pl._ovDid = did;
|
||||
_ovDidToPL[did] = pl;
|
||||
pl.on('click', () => _ovHighlightTrack(pl));
|
||||
pl.on('mouseover', () => { if (pl !== _ovHighlightedPL) pl.setOptions({ strokeWeight: 5 }); });
|
||||
pl.on('mouseout', () => { if (pl !== _ovHighlightedPL) pl.setOptions({ strokeWeight: 3 }); });
|
||||
_ovTrackMarkers.push(pl);
|
||||
// Tag markers with their polyline for highlight grouping
|
||||
deviceMarkers.forEach(m => { m._ovOwnerPL = pl; });
|
||||
}
|
||||
totalPoints += locs.length;
|
||||
} catch (err) {
|
||||
console.error(`Track load failed for ${devName}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (totalPoints === 0) { showToast('今天没有轨迹数据', 'info'); return; }
|
||||
|
||||
if (_ovTrackMarkers.length > 1) {
|
||||
_overviewMap.setFitView(_ovTrackMarkers.filter(m => m.getPosition), false, [80,80,80,80]);
|
||||
}
|
||||
document.getElementById('ovBtnClearTrack').style.display = '';
|
||||
document.getElementById('ovBtnTrack').innerHTML = `<i class="fas fa-route"></i> 轨迹 ${ids.length}台 (${totalPoints}点)`;
|
||||
showToast(`已加载 ${ids.length} 台设备共 ${totalPoints} 个轨迹点`);
|
||||
}
|
||||
|
||||
function _ovClearTrack() {
|
||||
_ovTrackMarkers.forEach(m => m.setMap(null));
|
||||
_ovTrackMarkers = [];
|
||||
_ovHighlightedPL = null;
|
||||
_ovDidToPL = {};
|
||||
document.getElementById('ovBtnClearTrack').style.display = 'none';
|
||||
document.getElementById('ovBtnTrack').innerHTML = '<i class="fas fa-route"></i> 显示轨迹';
|
||||
}
|
||||
|
||||
function _ovToggleLP() {
|
||||
_ovHideLP = !_ovHideLP;
|
||||
const btn = document.getElementById('ovBtnHideLP');
|
||||
if (_ovHideLP) {
|
||||
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-render track if active
|
||||
if (_ovTrackMarkers.length) _ovShowTrack();
|
||||
}
|
||||
|
||||
// ==================== LOCATIONS ====================
|
||||
function initLocationMap() {
|
||||
if (locationMap) return;
|
||||
@@ -2308,12 +2915,14 @@
|
||||
btn.style.color = '';
|
||||
btn.innerHTML = '<i class="fas fa-eye"></i> 低精度';
|
||||
}
|
||||
// Re-apply to existing track markers
|
||||
// Re-apply to existing track markers + polyline
|
||||
_applyLowPrecisionFilter();
|
||||
// Reload table with correct pagination
|
||||
loadLocationRecords(1);
|
||||
}
|
||||
function _isLowPrecision(locationType) {
|
||||
const t = (locationType || '').toLowerCase();
|
||||
return t.startsWith('lbs') || t.startsWith('wifi');
|
||||
return t.startsWith('lbs');
|
||||
}
|
||||
function _applyLowPrecisionFilter() {
|
||||
// Toggle visibility of low-precision markers stored with _lpFlag
|
||||
@@ -2413,14 +3022,18 @@
|
||||
fillOpacity: isLbs ? 0.6 : 0.9,
|
||||
zIndex: 120, cursor: 'pointer',
|
||||
});
|
||||
const isLP = (isLbs || isWifi) && !isFirst && !isLast;
|
||||
const isLP = isLbs && !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) });
|
||||
marker.on('click', () => infoWindow.open(locationMap, [mLng, mLat]));
|
||||
let _trackPinned = false;
|
||||
marker.on('mouseover', () => { if (!_trackPinned) infoWindow.open(locationMap, [mLng, mLat]); });
|
||||
marker.on('mouseout', () => { if (!_trackPinned) infoWindow.close(); });
|
||||
marker.on('click', () => { _trackPinned = !_trackPinned; infoWindow.open(locationMap, [mLng, mLat]); });
|
||||
infoWindow.on('close', () => { _trackPinned = false; });
|
||||
mapMarkers.push(marker);
|
||||
mapInfoWindows.push({ infoWindow, position: [mLng, mLat], locId: loc.id });
|
||||
}
|
||||
@@ -2433,8 +3046,7 @@
|
||||
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'));
|
||||
return !lt.startsWith('lbs');
|
||||
})
|
||||
: path;
|
||||
|
||||
@@ -2521,7 +3133,11 @@
|
||||
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]));
|
||||
let _latestPinned = true; // pinned by default for latest position
|
||||
marker.on('mouseover', () => { if (!_latestPinned) infoWindow.open(locationMap, [mLng, mLat]); });
|
||||
marker.on('mouseout', () => { if (!_latestPinned) infoWindow.close(); });
|
||||
marker.on('click', () => { _latestPinned = !_latestPinned; infoWindow.open(locationMap, [mLng, mLat]); });
|
||||
infoWindow.on('close', () => { _latestPinned = false; });
|
||||
mapMarkers.push(marker);
|
||||
locationMap.setCenter([mLng, mLat]);
|
||||
locationMap.setZoom(15);
|
||||
@@ -2547,6 +3163,7 @@
|
||||
let url = `${API_BASE}/locations?page=${p}&page_size=${ps}`;
|
||||
if (deviceId) url += `&device_id=${deviceId}`;
|
||||
if (locType) url += `&location_type=${locType}`;
|
||||
if (_hideLowPrecision && !locType) url += `&exclude_type=lbs`;
|
||||
if (startTime) url += `&start_time=${startTime}T00:00:00`;
|
||||
if (endTime) url += `&end_time=${endTime}T23:59:59`;
|
||||
|
||||
@@ -2563,10 +3180,9 @@
|
||||
tbody.innerHTML = items.map(l => {
|
||||
const q = _locQuality(l);
|
||||
const hasCoord = l.latitude != null && l.longitude != null;
|
||||
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})"` : ''}>
|
||||
return `<tr data-loc-type="${l.location_type || ''}" 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 class="font-mono text-xs">${escapeHtml(_imei(l.device_id))}</td>
|
||||
<td>${_locTypeLabel(l.location_type)}</td>
|
||||
<td>${l.latitude != null ? Number(l.latitude).toFixed(6) : '-'}</td>
|
||||
<td>${l.longitude != null ? Number(l.longitude).toFixed(6) : '-'}</td>
|
||||
@@ -2634,7 +3250,7 @@
|
||||
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 class="font-mono text-xs">${escapeHtml(_imei(a.device_id))}</td>
|
||||
<td><span class="${alarmTypeClass(a.alarm_type)} font-semibold">${alarmTypeName(a.alarm_type)}</span></td>
|
||||
<td>${({'single_fence':'<span style="color:#a855f7"><i class="fas fa-draw-polygon mr-1"></i>单围栏</span>','multi_fence':'<span style="color:#c084fc"><i class="fas fa-layer-group mr-1"></i>多围栏</span>','lbs':'<span style="color:#f97316"><i class="fas fa-broadcast-tower mr-1"></i>基站</span>','wifi':'<span style="color:#f59e0b"><i class="fas fa-wifi mr-1"></i>WiFi</span>'})[a.alarm_source] || escapeHtml(a.alarm_source || '-')}</td>
|
||||
<td class="text-xs">${a.address ? escapeHtml(a.address) : (a.latitude != null ? Number(a.latitude).toFixed(6) + ', ' + Number(a.longitude).toFixed(6) : '-')}</td>
|
||||
@@ -2743,7 +3359,7 @@
|
||||
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 class="font-mono text-xs">${escapeHtml(_imei(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">${locMethod} ${escapeHtml(posStr)}</td>
|
||||
@@ -2813,7 +3429,7 @@
|
||||
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 class="font-mono text-xs">${escapeHtml(_imei(b.device_id))}</td>
|
||||
<td>${typeIcon} <span class="font-semibold">${typeName}</span></td>
|
||||
<td class="font-mono text-xs">${escapeHtml(mac)}</td>
|
||||
<td class="text-xs">${uuidLine}</td>
|
||||
@@ -2970,8 +3586,7 @@
|
||||
<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>
|
||||
<td class="font-mono text-xs">${escapeHtml(r.imei || (logDeviceMap[r.device_id] || {}).imei || '-')}</td>
|
||||
<td class="font-mono text-xs">${escapeHtml(r.imei || _imei(r.device_id))}</td>
|
||||
<td class="text-xs" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(detail)}">${escapeHtml(detail)}</td>
|
||||
<td class="font-mono text-xs">${coord}</td>
|
||||
<td class="text-xs">${escapeHtml(addr)}</td>
|
||||
@@ -4089,7 +4704,7 @@
|
||||
tbody.innerHTML = items.map(c => `
|
||||
<tr>
|
||||
<td class="font-mono text-xs">${escapeHtml(c.id || '-')}</td>
|
||||
<td class="font-mono text-xs">${escapeHtml(c.device_id || c.imei || '-')}</td>
|
||||
<td class="font-mono text-xs">${escapeHtml(_imei(c.device_id))}</td>
|
||||
<td>${escapeHtml(c.command_type || '-')}</td>
|
||||
<td class="text-xs" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(c.command_content || '')}">${escapeHtml(truncate(c.command_content || '-', 40))}</td>
|
||||
<td>${commandStatusBadge(c.status)}</td>
|
||||
|
||||
Reference in New Issue
Block a user