Add batch management APIs, API security, rate limiting, and optimizations

- Batch device CRUD: POST /api/devices/batch (create 500), PUT /api/devices/batch (update 500),
  POST /api/devices/batch-delete (delete 100) with WHERE IN bulk queries
- Batch command: POST /api/commands/batch with model_validator mutual exclusion
- API key auth (X-API-Key header, secrets.compare_digest timing-safe)
- Rate limiting via SlowAPIMiddleware (60/min default, 30/min writes)
- Real client IP extraction (X-Forwarded-For / CF-Connecting-IP)
- Global exception handler (no stack trace leaks, passes HTTPException through)
- CORS with auto-disable credentials on wildcard origins
- Schema validation: IMEI pattern, lat/lon ranges, Literal enums, MAC/UUID patterns
- Heartbeats router, per-ID endpoints for locations/attendance/bluetooth
- Input dedup in batch create, result ordering preserved
- Baidu reverse geocoding, Gaode map tiles with WGS84→GCJ02 conversion
- Device detail panel with feature toggles and command controls
- Side panel for location/beacon pages with auto-select active device

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

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-20 09:18:43 +00:00
parent 1bdbe4fa19
commit 7d6040af41
23 changed files with 1564 additions and 294 deletions

View File

@@ -26,9 +26,9 @@
.page.active { display: block; }
.stat-card { background: #1f2937; border-radius: 12px; padding: 24px; border: 1px solid #374151; transition: transform 0.2s, box-shadow 0.2s; }
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.3); }
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 50; display: flex; align-items: center; justify-content: center; }
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal-content { background: #1f2937; border-radius: 12px; padding: 24px; max-width: 600px; width: 90%; max-height: 85vh; overflow-y: auto; border: 1px solid #374151; }
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 100; display: flex; flex-direction: column; gap: 8px; }
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 8px; }
.toast { padding: 12px 20px; border-radius: 8px; color: white; font-size: 14px; animation: slideIn 0.3s ease; min-width: 250px; display: flex; align-items: center; gap: 8px; }
.toast.success { background: #059669; }
.toast.error { background: #dc2626; }
@@ -99,6 +99,42 @@
.guide-tips { margin-top: 10px; padding: 10px 14px; background: rgba(59,130,246,0.06); border-radius: 8px; border-left: 3px solid #3b82f6; }
.guide-tips p { font-size: 12px; color: #64748b; line-height: 1.6; }
.guide-tips p i { color: #3b82f6; margin-right: 4px; }
/* === Side Panel === */
.page-with-panel { display: flex; gap: 16px; height: calc(100vh - 140px); }
.side-panel { width: 280px; min-width: 280px; background: #1f2937; border: 1px solid #374151; border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; transition: width 0.3s, min-width 0.3s, opacity 0.3s; }
.side-panel.collapsed { width: 0; min-width: 0; border: none; opacity: 0; }
.page-main-content { flex: 1; min-width: 0; overflow-y: auto; }
.panel-header { padding: 12px 14px; border-bottom: 1px solid #374151; display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.panel-header .panel-title { font-size: 14px; font-weight: 600; color: #e5e7eb; flex: 1; }
.panel-toggle-btn { background: none; border: none; color: #9ca3af; cursor: pointer; padding: 4px; font-size: 14px; }
.panel-toggle-btn:hover { color: #e5e7eb; }
.panel-search { padding: 8px 12px; border-bottom: 1px solid #374151; flex-shrink: 0; }
.panel-search-wrap { position: relative; }
.panel-search-wrap i { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: #6b7280; font-size: 12px; }
.panel-search-wrap input { padding-left: 30px; font-size: 13px; }
.panel-list { flex: 1; overflow-y: auto; padding: 8px; }
.panel-item { padding: 10px 12px; border-radius: 8px; cursor: pointer; border: 1px solid transparent; margin-bottom: 6px; transition: all 0.15s; position: relative; }
.panel-item:hover { background: #374151; border-color: #4b5563; }
.panel-item.active { background: #1e3a5f; border-color: #2563eb; }
.panel-item-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
.panel-item-name { font-size: 13px; font-weight: 600; color: #e5e7eb; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 170px; }
.panel-item-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.panel-item-status.online { background: #34d399; }
.panel-item-status.offline { background: #f87171; }
.panel-item-status.active { background: #34d399; }
.panel-item-status.inactive { background: #f87171; }
.panel-item-sub { font-size: 11px; color: #9ca3af; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.panel-item-meta { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 11px; color: #6b7280; }
.battery-bar { width: 28px; height: 10px; background: #374151; border-radius: 2px; border: 1px solid #4b5563; overflow: hidden; display: inline-block; }
.battery-bar-fill { height: 100%; border-radius: 1px; }
.panel-item-actions { position: absolute; right: 8px; top: 8px; display: none; gap: 4px; }
.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; }
.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; }
@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; } }
</style>
</head>
<body>
@@ -366,50 +402,70 @@
<div class="guide-tips"><p><i class="fas fa-map-marked-alt"></i> 轨迹以蓝色折线显示,绿色标记为起点,红色标记为终点</p></div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 mb-4">
<select id="locDeviceSelect" style="width:200px">
<option value="">选择设备...</option>
</select>
<select id="locTypeFilter" style="width:150px">
<option value="">全部类型</option>
<option value="gps">GPS</option>
<option value="gps_4g">GPS 4G</option>
<option value="wifi">WiFi</option>
<option value="wifi_4g">WiFi 4G</option>
<option value="lbs">LBS</option>
<option value="lbs_4g">LBS 4G</option>
</select>
<input type="date" id="locStartDate" style="width:160px">
<input type="date" id="locEndDate" style="width:160px">
<button class="btn btn-primary" onclick="loadTrack()"><i class="fas fa-route"></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>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden mb-6" style="height: 500px;">
<div id="locationMap" style="height: 100%; width: 100%;"></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>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>设备ID</th>
<th>类型</th>
<th>纬度</th>
<th>经度</th>
<th>地址</th>
<th>速度</th>
<th>卫星数</th>
<th>时间</th>
</tr>
</thead>
<tbody id="locationsTableBody">
<tr><td colspan="8" class="text-center text-gray-500 py-8">请选择设备并查询</td></tr>
</tbody>
</table>
<div class="page-with-panel">
<!-- Left: Device Panel -->
<div class="side-panel" id="locSidePanel">
<div class="panel-header">
<i class="fas fa-microchip text-blue-400" style="font-size:14px"></i>
<span class="panel-title">设备列表</span>
<span id="locPanelCount" style="font-size:11px;color:#6b7280"></span>
<button class="panel-toggle-btn" onclick="toggleSidePanel('locSidePanel')" title="收起面板"><i class="fas fa-chevron-left"></i></button>
</div>
<div class="panel-search"><div class="panel-search-wrap"><i class="fas fa-search"></i><input type="text" id="locPanelSearch" placeholder="搜索设备..." oninput="filterPanelItems('locations')"></div></div>
<div class="panel-list" id="locPanelList">
<div style="text-align:center;padding:20px;color:#6b7280"><div class="spinner"></div><p style="margin-top:8px;font-size:12px">加载设备...</p></div>
</div>
<div class="panel-footer" id="locPanelFooter">加载中...</div>
</div>
<!-- Right: Main Content -->
<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>
<select id="locTypeFilter" style="width:150px">
<option value="">全部类型</option>
<option value="gps">GPS</option>
<option value="gps_4g">GPS 4G</option>
<option value="wifi">WiFi</option>
<option value="wifi_4g">WiFi 4G</option>
<option value="lbs">LBS</option>
<option value="lbs_4g">LBS 4G</option>
</select>
<input type="date" id="locStartDate" style="width:160px">
<input type="date" id="locEndDate" style="width:160px">
<button class="btn btn-primary" onclick="loadTrack()"><i class="fas fa-route"></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>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden mb-6" style="height: 500px;">
<div id="locationMap" style="height: 100%; width: 100%;"></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>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>设备ID</th>
<th>类型</th>
<th>纬度</th>
<th>经度</th>
<th>地址</th>
<th>速度</th>
<th>卫星数</th>
<th>时间</th>
</tr>
</thead>
<tbody id="locationsTableBody">
<tr><td colspan="8" class="text-center text-gray-500 py-8">请选择设备并查询</td></tr>
</tbody>
</table>
</div>
<div id="locationsPagination" class="pagination p-4"></div>
</div>
</div>
<div id="locationsPagination" class="pagination p-4"></div>
</div>
</div>
@@ -660,43 +716,60 @@
</div>
</div>
<!-- Filters & Controls -->
<div class="flex flex-wrap items-center gap-3 mb-4">
<input type="text" id="beaconSearch" placeholder="搜索 MAC / 名称 / 区域" style="width:220px">
<select id="beaconStatusFilter" style="width:150px">
<option value="">全部状态</option>
<option value="active">启用</option>
<option value="inactive">停用</option>
</select>
<button class="btn btn-primary" onclick="loadBeacons()"><i class="fas fa-search"></i> 查询</button>
<button class="btn btn-secondary" onclick="loadBeacons()"><i class="fas fa-sync-alt"></i> 刷新</button>
<div style="flex:1"></div>
<button class="btn btn-primary" onclick="showAddBeaconModal()"><i class="fas fa-plus"></i> 添加信标</button>
</div>
<!-- Table -->
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="beaconsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>MAC 地址</th>
<th>名称</th>
<th>UUID / Major / Minor</th>
<th>楼层 / 区域</th>
<th>坐标</th>
<th>状态</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="beaconsTableBody">
<tr><td colspan="8" class="text-center text-gray-500 py-8">加载中...</td></tr>
</tbody>
</table>
<div class="page-with-panel">
<!-- Left: Beacon Panel -->
<div class="side-panel" id="beaconSidePanel">
<div class="panel-header">
<i class="fas fa-broadcast-tower text-green-400" style="font-size:14px"></i>
<span class="panel-title">信标列表</span>
<span id="beaconPanelCount" style="font-size:11px;color:#6b7280"></span>
<button class="panel-toggle-btn" onclick="toggleSidePanel('beaconSidePanel')" title="收起面板"><i class="fas fa-chevron-left"></i></button>
</div>
<div class="panel-search"><div class="panel-search-wrap"><i class="fas fa-search"></i><input type="text" id="beaconPanelSearch" placeholder="搜索信标..." oninput="filterPanelItems('beacons')"></div></div>
<div class="panel-list" id="beaconPanelList">
<div style="text-align:center;padding:20px;color:#6b7280"><div class="spinner"></div><p style="margin-top:8px;font-size:12px">加载信标...</p></div>
</div>
<div class="panel-footer" id="beaconPanelFooter">加载中...</div>
</div>
<!-- Right: Main Content -->
<div class="page-main-content" style="position:relative">
<button class="panel-expand-btn" onclick="toggleSidePanel('beaconSidePanel')" title="展开信标面板"><i class="fas fa-chevron-right"></i></button>
<div class="flex flex-wrap items-center gap-3 mb-4">
<input type="text" id="beaconSearch" placeholder="搜索 MAC / 名称 / 区域" style="width:220px">
<select id="beaconStatusFilter" style="width:150px">
<option value="">全部状态</option>
<option value="active">启用</option>
<option value="inactive">停用</option>
</select>
<button class="btn btn-primary" onclick="loadBeacons()"><i class="fas fa-search"></i> 查询</button>
<button class="btn btn-secondary" onclick="loadBeacons()"><i class="fas fa-sync-alt"></i> 刷新</button>
<div style="flex:1"></div>
<button class="btn btn-primary" onclick="showAddBeaconModal()"><i class="fas fa-plus"></i> 添加信标</button>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="beaconsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>MAC 地址</th>
<th>名称</th>
<th>UUID / Major / Minor</th>
<th>楼层 / 区域</th>
<th>坐标</th>
<th>状态</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="beaconsTableBody">
<tr><td colspan="8" class="text-center text-gray-500 py-8">加载中...</td></tr>
</tbody>
</table>
</div>
<div id="beaconsPagination" class="pagination p-4"></div>
</div>
</div>
<div id="beaconsPagination" class="pagination p-4"></div>
</div>
</div>
@@ -827,6 +900,12 @@
let dashAlarmChart = null;
let alarmTypeChart = null;
// Side panel state
let panelDevices = [];
let panelBeacons = [];
let selectedPanelDeviceId = null;
let selectedPanelBeaconId = null;
// Pagination state
const pageState = {
devices: { page: 1, pageSize: 20 },
@@ -1028,6 +1107,9 @@
document.getElementById('pageTitle').textContent = pageTitles[page] || page;
if (dashboardInterval) { clearInterval(dashboardInterval); dashboardInterval = null; }
// Clean up panel state when leaving panel pages
if (page !== 'locations') { selectedPanelDeviceId = null; panelDevices = []; }
if (page !== 'beacons') { selectedPanelBeaconId = null; panelBeacons = []; }
switch (page) {
case 'dashboard': loadDashboard(); dashboardInterval = setInterval(loadDashboard, 30000); break;
@@ -1041,6 +1123,166 @@
}
}
// ==================== SIDE PANEL ====================
// Panel element ID config (centralized, easy to extend for new panel pages)
const PANEL_IDS = {
locations: { panel: 'locSidePanel', list: 'locPanelList', count: 'locPanelCount', footer: 'locPanelFooter', search: 'locPanelSearch' },
beacons: { panel: 'beaconSidePanel', list: 'beaconPanelList', count: 'beaconPanelCount', footer: 'beaconPanelFooter', search: 'beaconPanelSearch' },
};
const PANEL_TRANSITION_MS = 300; // must match CSS transition duration
function toggleSidePanel(panelId) {
const panel = document.getElementById(panelId);
if (!panel) return;
panel.classList.toggle('collapsed');
// Only invalidate map size when on locations page
if (currentPage === 'locations' && locationMap) {
setTimeout(() => locationMap.invalidateSize(), PANEL_TRANSITION_MS + 50);
}
}
function formatTimeAgo(t) {
if (!t) return '-';
try {
const diff = Math.floor((Date.now() - new Date(t).getTime()) / 1000);
if (isNaN(diff) || diff < 0) return '-';
if (diff < 60) return '刚刚';
if (diff < 3600) return Math.floor(diff / 60) + '分钟前';
if (diff < 86400) return Math.floor(diff / 3600) + '小时前';
if (diff < 2592000) return Math.floor(diff / 86400) + '天前';
return formatTime(t);
} catch { return '-'; }
}
/** Generic panel render helper — sets count & footer text, returns container or null */
function _initPanelRender(ids, items, statusField, statusValue, emptyText, countFmt, footerFmt) {
const container = document.getElementById(ids.list);
const countEl = document.getElementById(ids.count);
const footerEl = document.getElementById(ids.footer);
if (!container) return null;
const activeCount = items.filter(it => it[statusField] === statusValue).length;
if (countEl) countEl.textContent = countFmt(activeCount, items.length);
if (footerEl) footerEl.textContent = footerFmt(activeCount, items.length);
if (items.length === 0) {
container.innerHTML = `<div style="text-align:center;padding:20px;color:#6b7280;font-size:13px">${emptyText}</div>`;
return null;
}
return container;
}
function renderDevicePanel(devices) {
panelDevices = devices;
const container = _initPanelRender(
PANEL_IDS.locations, devices, 'status', 'online', '暂无设备',
(a, t) => `${a}/${t}`, (a, t) => `${t} 台设备,${a} 台在线`
);
if (!container) return;
container.innerHTML = devices.map(d => {
const isActive = (d.id || d.device_id) == selectedPanelDeviceId;
const statusClass = d.status === 'online' ? 'online' : 'offline';
const deviceId = d.id || d.device_id || '';
const imeiShort = d.imei ? '...' + d.imei.slice(-8) : '-';
const bp = d.battery_level;
const bColor = bp != null ? (bp < 20 ? '#f87171' : bp < 50 ? '#fbbf24' : '#34d399') : '#4b5563';
const lastActive = d.last_heartbeat || d.last_login;
const timeAgo = lastActive ? formatTimeAgo(lastActive) : '无活动';
return `<div class="panel-item ${isActive ? 'active' : ''}" data-device-id="${deviceId}" data-search-text="${(d.name||'').toLowerCase()} ${(d.imei||'').toLowerCase()}" onclick="selectPanelDevice('${deviceId}')">
<div class="panel-item-actions">
<button class="panel-action-btn" onclick="event.stopPropagation();selectPanelDevice('${deviceId}')" title="定位"><i class="fas fa-crosshairs"></i></button>
<button class="panel-action-btn" onclick="event.stopPropagation();showDeviceDetail('${deviceId}')" title="详情"><i class="fas fa-info-circle"></i></button>
</div>
<div class="panel-item-header">
<span class="panel-item-name">${escapeHtml(d.name || d.imei || deviceId)}</span>
<div class="panel-item-status ${statusClass}"></div>
</div>
<div class="panel-item-sub">${escapeHtml(imeiShort)}</div>
<div class="panel-item-meta">
${bp != null ? `<span style="display:flex;align-items:center;gap:3px"><span class="battery-bar"><span class="battery-bar-fill" style="width:${bp}%;background:${bColor}"></span></span><span>${bp}%</span></span>` : ''}
${d.gsm_signal != null ? `<span><i class="fas fa-signal" style="font-size:10px"></i> ${d.gsm_signal}</span>` : ''}
<span style="margin-left:auto"><i class="far fa-clock" style="font-size:10px"></i> ${timeAgo}</span>
</div>
</div>`;
}).join('');
}
function renderBeaconPanel(beacons) {
panelBeacons = beacons;
const container = _initPanelRender(
PANEL_IDS.beacons, beacons, 'status', 'active', '暂无信标',
(a, t) => `${a}/${t}`, (a, t) => `${t} 个信标,${a} 个启用`
);
if (!container) return;
container.innerHTML = beacons.map(b => {
const isActive = b.id == selectedPanelBeaconId;
const statusClass = b.status === 'active' ? 'active' : 'inactive';
const floorArea = [b.floor, b.area].filter(Boolean).join(' / ') || '未设置';
const macShort = b.beacon_mac ? b.beacon_mac.slice(-8) : '-';
return `<div class="panel-item ${isActive ? 'active' : ''}" data-beacon-id="${b.id}" data-search-text="${(b.name||'').toLowerCase()} ${(b.beacon_mac||'').toLowerCase()} ${(b.area||'').toLowerCase()}" onclick="selectPanelBeacon(${b.id})">
<div class="panel-item-actions">
<button class="panel-action-btn" onclick="event.stopPropagation();showEditBeaconModal(${b.id})" title="编辑"><i class="fas fa-edit"></i></button>
</div>
<div class="panel-item-header">
<span class="panel-item-name">${escapeHtml(b.name || b.beacon_mac)}</span>
<div class="panel-item-status ${statusClass}"></div>
</div>
<div class="panel-item-sub">${escapeHtml(macShort)}</div>
<div class="panel-item-meta">
<span><i class="fas fa-layer-group" style="font-size:10px"></i> ${escapeHtml(floorArea)}</span>
${b.latitude && b.longitude ? '<span style="color:#34d399"><i class="fas fa-map-pin" style="font-size:10px"></i> 已定位</span>' : '<span style="color:#6b7280">未定位</span>'}
</div>
</div>`;
}).join('');
}
function filterPanelItems(type) {
const ids = PANEL_IDS[type];
if (!ids) return;
const inputEl = document.getElementById(ids.search);
const listEl = document.getElementById(ids.list);
if (!inputEl || !listEl) return;
const keyword = (inputEl.value || '').toLowerCase().trim();
listEl.querySelectorAll('.panel-item').forEach(item => {
item.style.display = (!keyword || (item.dataset.searchText || '').includes(keyword)) ? '' : 'none';
});
}
function selectPanelDevice(deviceId, autoLocate = true) {
selectedPanelDeviceId = deviceId;
document.querySelectorAll('#locPanelList .panel-item').forEach(el => {
el.classList.toggle('active', el.dataset.deviceId == deviceId);
});
const select = document.getElementById('locDeviceSelect');
if (select) select.value = deviceId;
const activeCard = document.querySelector(`#locPanelList .panel-item[data-device-id="${deviceId}"]`);
if (activeCard) activeCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
if (autoLocate && deviceId) loadLatestPosition();
}
function selectPanelBeacon(beaconId) {
selectedPanelBeaconId = beaconId;
document.querySelectorAll('#beaconPanelList .panel-item').forEach(el => {
el.classList.toggle('active', el.dataset.beaconId == beaconId);
});
}
function sortDevicesByActivity(devices) {
return [...devices].sort((a, b) => {
if (a.status === 'online' && b.status !== 'online') return -1;
if (a.status !== 'online' && b.status === 'online') return 1;
const tA = new Date(a.last_heartbeat || a.last_login || 0).getTime();
const tB = new Date(b.last_heartbeat || b.last_login || 0).getTime();
return tB - tA;
});
}
function autoSelectActiveDevice(devices) {
if (!devices || devices.length === 0) return;
const sorted = sortDevicesByActivity(devices);
const best = sorted[0];
const bestId = best.id || best.device_id;
if (bestId) selectPanelDevice(bestId, true);
}
// ==================== DEVICE SELECTOR HELPER ====================
let cachedDevices = null;
@@ -1064,6 +1306,12 @@
});
if (currentVal) sel.value = currentVal;
});
// Render device panel on locations page
if (currentPage === 'locations' && document.getElementById('locPanelList')) {
const sorted = sortDevicesByActivity(devices);
renderDevicePanel(sorted);
if (!selectedPanelDeviceId) autoSelectActiveDevice(sorted);
}
} catch (err) {
console.error('Failed to load device selectors:', err);
}
@@ -1264,31 +1512,159 @@
}
}
function _locTypeLabel(t) {
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 _locModeBadges(locType) {
const modes = [
{ label: 'GPS', match: ['gps','gps_4g'] },
{ label: 'LBS', match: ['lbs','lbs_4g'] },
{ label: 'WiFi', match: ['wifi','wifi_4g'] },
{ label: '蓝牙', match: ['bluetooth'] },
];
return modes.map(m => {
const active = m.match.includes(locType);
const color = active ? '#10b981' : '#4b5563';
const bg = active ? 'rgba(16,185,129,0.15)' : 'rgba(75,85,99,0.2)';
return `<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:12px;font-size:12px;border:1px solid ${color};background:${bg};color:${color};margin:2px">${active?'●':'○'} ${m.label}</span>`;
}).join('');
}
// --- Quick command sender for device detail panel ---
async function _quickCmd(deviceId, cmd, btnEl) {
if (btnEl) { btnEl.disabled = true; btnEl.style.opacity = '0.5'; }
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 (device replies via 0x81)
const cmdId = res && res.id;
if (cmdId) _pollCmdResponse(cmdId, cmd);
} catch (err) {
showToast(`发送失败: ${err.message}`, 'error');
} finally {
if (btnEl) { btnEl.disabled = false; btnEl.style.opacity = '1'; }
}
}
async function _pollCmdResponse(cmdId, cmdName) {
for (let i = 0; i < 6; i++) {
await new Promise(r => setTimeout(r, 1500));
try {
const cmd = await apiCall(`${API_BASE}/commands/${cmdId}`);
if (cmd.response_content) {
const el = document.getElementById('detailCmdResult');
if (el) el.innerHTML = `<span class="text-gray-400" style="font-size:11px">${escapeHtml(cmdName)}:</span> <span style="font-size:12px;color:#d1d5db">${escapeHtml(cmd.response_content)}</span>`;
return;
}
} catch (_) {}
}
}
async function _quickTts(deviceId) {
const input = document.getElementById('detailTtsInput');
if (!input || !input.value.trim()) { showToast('请输入语音内容', 'error'); return; }
try {
await apiCall(`${API_BASE}/commands/tts`, {
method: 'POST',
body: JSON.stringify({ device_id: parseInt(deviceId), text: input.value.trim() }),
});
showToast('TTS 已发送');
input.value = '';
} catch (err) {
showToast(`TTS失败: ${err.message}`, 'error');
}
}
async function showDeviceDetail(id) {
if (!id) return;
try {
const device = await apiCall(`${API_BASE}/devices/${id}`);
const [device, latestLoc] = await Promise.all([
apiCall(`${API_BASE}/devices/${id}`),
apiCall(`${API_BASE}/locations/latest/${id}`).catch(() => null),
]);
const d = device;
const did = d.id || d.device_id;
const loc = latestLoc;
const locType = loc ? loc.location_type : null;
const locTime = loc ? formatTime(loc.recorded_at) : '-';
const locAddr = loc && loc.address ? escapeHtml(loc.address) : '-';
const locCoord = loc && loc.latitude ? `${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}` : '-';
const online = d.status === 'online';
const disabledAttr = online ? '' : 'disabled style="opacity:0.4;cursor:not-allowed"';
const _btn = (icon, label, cmd, color) =>
`<button class="btn" style="font-size:12px;padding:5px 10px;background:${color};border:none;color:#fff;border-radius:6px;cursor:pointer" ${disabledAttr} onclick="_quickCmd('${did}','${cmd}',this)"><i class="fas fa-${icon}"></i> ${label}</button>`;
showModal(`
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-microchip mr-2 text-blue-400"></i>设备详情</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div><span class="text-gray-400">ID:</span><br><span class="font-mono">${escapeHtml(d.id || d.device_id)}</span></div>
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-microchip mr-2 text-blue-400"></i>设备详情${escapeHtml(d.name || d.imei)}</h3>
<!-- 基本信息 -->
<div class="grid grid-cols-2 gap-3 text-sm">
<div><span class="text-gray-400">IMEI:</span><br><span class="font-mono">${escapeHtml(d.imei)}</span></div>
<div><span class="text-gray-400">名称:</span><br>${escapeHtml(d.name || '-')}</div>
<div><span class="text-gray-400">类型:</span><br>${escapeHtml(d.device_type || '-')}</div>
<div><span class="text-gray-400">型号:</span><br>${escapeHtml(d.device_type || '-')}</div>
<div><span class="text-gray-400">状态:</span><br>${statusBadge(d.status)}</div>
<div><span class="text-gray-400">电量:</span><br>${d.battery_level !== undefined && d.battery_level !== null ? d.battery_level + '%' : '-'}</div>
<div><span class="text-gray-400">信号强度:</span><br>${d.signal_strength !== undefined && d.signal_strength !== null ? d.signal_strength : '-'}</div>
<div><span class="text-gray-400">固件版本:</span><br>${escapeHtml(d.firmware_version || '-')}</div>
<div><span class="text-gray-400">时区:</span><br>${escapeHtml(d.timezone || '-')}</div>
<div><span class="text-gray-400">语言:</span><br>${escapeHtml(d.language || '-')}</div>
<div><span class="text-gray-400">电量:</span><br>${d.battery_level != null ? d.battery_level + '%' : '-'} ${d.gsm_signal != null ? '&nbsp;|&nbsp; 信号: ' + d.gsm_signal : ''}</div>
<div><span class="text-gray-400">ICCID:</span><br><span class="font-mono" style="font-size:11px">${escapeHtml(d.iccid || '-')}</span></div>
<div><span class="text-gray-400">时区/语言:</span><br>${escapeHtml(d.timezone || '-')} / ${escapeHtml(d.language || '-')}</div>
<div><span class="text-gray-400">最后登录:</span><br>${formatTime(d.last_login)}</div>
<div><span class="text-gray-400">最后心跳:</span><br>${formatTime(d.last_heartbeat)}</div>
<div class="col-span-2"><span class="text-gray-400">创建时间:</span><br>${formatTime(d.created_at)}</div>
</div>
<div class="flex gap-3 mt-6">
<button class="btn btn-primary flex-1" onclick="showEditDeviceModal('${d.id || d.device_id}')"><i class="fas fa-edit"></i> 编辑</button>
<button class="btn btn-danger flex-1" onclick="confirmDeleteDevice('${d.id || d.device_id}', '${escapeHtml(d.name || d.imei)}')"><i class="fas fa-trash"></i> 删除</button>
<!-- 定位信息 -->
<div style="margin-top:14px;padding:12px;background:#111827;border-radius:8px;border:1px solid #374151">
<div style="font-size:13px;font-weight:600;color:#9ca3af;margin-bottom:8px"><i class="fas fa-map-marker-alt mr-1 text-green-400"></i>定位信息</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div><span class="text-gray-400">当前模式:</span><br><span style="color:#10b981;font-weight:600">${_locTypeLabel(locType)}</span></div>
<div><span class="text-gray-400">定位时间:</span><br>${locTime}</div>
<div class="col-span-2"><span class="text-gray-400">地址:</span><br>${locAddr}</div>
</div>
<div style="margin-top:8px">${_locModeBadges(locType)}</div>
</div>
<!-- 功能开关 -->
<div style="margin-top:14px;padding:12px;background:#111827;border-radius:8px;border:1px solid #374151">
<div style="font-size:13px;font-weight:600;color:#9ca3af;margin-bottom:10px"><i class="fas fa-sliders-h mr-1 text-yellow-400"></i>功能开关${online ? '' : ' <span style="color:#ef4444;font-weight:400;font-size:11px">(设备离线,无法操作)</span>'}</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">
${_btn('satellite-dish', 'GPS 开启', 'GPSON#', '#0d9488')}
${_btn('bluetooth-b', '蓝牙开启', 'BTON#', '#2563eb')}
${_btn('broadcast-tower', 'BLE扫描', 'BTSCAN,1#', '#7c3aed')}
</div>
<div style="font-size:11px;color:#6b7280;margin-bottom:10px">工作模式:</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">
${_btn('clock', '定时定位', 'MODE,1#', '#4b5563')}
${_btn('brain', '智能模式', 'MODE,3#', '#4b5563')}
</div>
<div style="font-size:11px;color:#6b7280;margin-bottom:6px">信息查询:</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
${_btn('info-circle', '设备状态', 'STATUS#', '#374151')}
${_btn('cog', '参数查询', 'PARAM#', '#374151')}
${_btn('code-branch', '固件版本', 'VERSION#', '#374151')}
${_btn('stopwatch', '定时器', 'TIMER#', '#374151')}
${_btn('clipboard-check', '完整信息', 'CHECK#', '#374151')}
</div>
<div id="detailCmdResult" style="margin-top:10px;padding:8px;background:#0d1117;border-radius:6px;min-height:20px;font-family:monospace;word-break:break-all;display:${online?'block':'none'}"></div>
</div>
<!-- TTS -->
<div style="margin-top:14px;padding:12px;background:#111827;border-radius:8px;border:1px solid #374151${online?'':'display:none'}">
<div style="font-size:13px;font-weight:600;color:#9ca3af;margin-bottom:8px"><i class="fas fa-volume-up mr-1 text-purple-400"></i>语音播报 (TTS)</div>
<div style="display:flex;gap:8px">
<input id="detailTtsInput" type="text" placeholder="输入语音内容 (最多200字)" maxlength="200" style="flex:1;background:#1f2937;border:1px solid #374151;border-radius:6px;padding:6px 10px;color:#e5e7eb;font-size:13px" ${online?'':'disabled'}>
<button class="btn" style="font-size:12px;padding:6px 14px;background:#7c3aed;border:none;color:#fff;border-radius:6px;white-space:nowrap" ${disabledAttr} onclick="_quickTts('${did}')"><i class="fas fa-play"></i> 播报</button>
</div>
</div>
<!-- 危险操作 -->
<div style="margin-top:14px;padding:12px;background:rgba(127,29,29,0.15);border-radius:8px;border:1px solid #7f1d1d${online?'':'display:none'}">
<div style="font-size:13px;font-weight:600;color:#fca5a5;margin-bottom:8px"><i class="fas fa-exclamation-triangle mr-1"></i>系统操作</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn" style="font-size:12px;padding:5px 10px;background:#991b1b;border:none;color:#fff;border-radius:6px" ${disabledAttr} onclick="if(confirm('确定重启设备?'))_quickCmd('${did}','RESET#',this)"><i class="fas fa-power-off"></i> 重启设备</button>
</div>
</div>
<div class="flex gap-3 mt-5">
<button class="btn btn-primary flex-1" onclick="showEditDeviceModal('${did}')"><i class="fas fa-edit"></i> 编辑</button>
<button class="btn btn-danger flex-1" onclick="confirmDeleteDevice('${did}', '${escapeHtml(d.name || d.imei)}')"><i class="fas fa-trash"></i> 删除</button>
<button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 关闭</button>
</div>
`);
@@ -1364,15 +1740,69 @@
}
}
// ==================== MAP PROVIDER ====================
// Switch tile provider: 'gaode' | 'tianditu'
// 高德: GCJ-02 tiles, need coordinate conversion; 天地图: WGS-84 native
const MAP_PROVIDER = 'gaode';
// WGS-84 → GCJ-02 coordinate conversion (for 高德 tiles)
const _gcj_a = 6378245.0, _gcj_ee = 0.00669342162296594;
function _outOfChina(lat, lng) { return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271; }
function _transformLat(x, y) {
let r = -100.0 + 2.0*x + 3.0*y + 0.2*y*y + 0.1*x*y + 0.2*Math.sqrt(Math.abs(x));
r += (20.0*Math.sin(6.0*x*Math.PI) + 20.0*Math.sin(2.0*x*Math.PI)) * 2.0/3.0;
r += (20.0*Math.sin(y*Math.PI) + 40.0*Math.sin(y/3.0*Math.PI)) * 2.0/3.0;
r += (160.0*Math.sin(y/12.0*Math.PI) + 320.0*Math.sin(y*Math.PI/30.0)) * 2.0/3.0;
return r;
}
function _transformLng(x, y) {
let r = 300.0 + x + 2.0*y + 0.1*x*x + 0.1*x*y + 0.1*Math.sqrt(Math.abs(x));
r += (20.0*Math.sin(6.0*x*Math.PI) + 20.0*Math.sin(2.0*x*Math.PI)) * 2.0/3.0;
r += (20.0*Math.sin(x*Math.PI) + 40.0*Math.sin(x/3.0*Math.PI)) * 2.0/3.0;
r += (150.0*Math.sin(x/12.0*Math.PI) + 300.0*Math.sin(x/30.0*Math.PI)) * 2.0/3.0;
return r;
}
function wgs84ToGcj02(lat, lng) {
if (_outOfChina(lat, lng)) return [lat, lng];
let dLat = _transformLat(lng - 105.0, lat - 35.0);
let dLng = _transformLng(lng - 105.0, lat - 35.0);
const radLat = lat / 180.0 * Math.PI;
let magic = Math.sin(radLat);
magic = 1 - _gcj_ee * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((_gcj_a * (1 - _gcj_ee)) / (magic * sqrtMagic) * Math.PI);
dLng = (dLng * 180.0) / (_gcj_a / sqrtMagic * Math.cos(radLat) * Math.PI);
return [lat + dLat, lng + dLng];
}
// Convert WGS-84 coords for current map provider
function toMapCoord(lat, lng) {
if (MAP_PROVIDER === 'gaode') return wgs84ToGcj02(lat, lng);
return [lat, lng]; // tianditu uses WGS-84 natively
}
// ==================== LOCATIONS ====================
function initLocationMap() {
if (locationMap) return;
setTimeout(() => {
locationMap = L.map('locationMap').setView([39.9042, 116.4074], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19,
}).addTo(locationMap);
locationMap = L.map('locationMap').setView(toMapCoord(39.9042, 116.4074), 10);
if (MAP_PROVIDER === 'gaode') {
// 高德矢量底图 (GCJ-02, standard Mercator, no API key needed)
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=2&style=8&x={x}&y={y}&z={z}', {
subdomains: '1234', maxZoom: 18,
attribution: '&copy; 高德地图',
}).addTo(locationMap);
} else {
// 天地图矢量底图 + 中文注记 (WGS-84)
const TDT_KEY = '1918548e81a5ae3ff0cb985537341146';
L.tileLayer('https://t{s}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}&tk=' + TDT_KEY, {
subdomains: ['0','1','2','3','4','5','6','7'], maxZoom: 18,
}).addTo(locationMap);
L.tileLayer('https://t{s}.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}&tk=' + TDT_KEY, {
subdomains: ['0','1','2','3','4','5','6','7'], maxZoom: 18,
attribution: '&copy; 天地图',
}).addTo(locationMap);
}
locationMap.invalidateSize();
}, 100);
}
@@ -1420,10 +1850,11 @@
const lat = loc.latitude || loc.lat;
const lng = loc.longitude || loc.lng || loc.lon;
if (lat && lng) {
latlngs.push([lat, lng]);
const [mLat, mLng] = toMapCoord(lat, lng);
latlngs.push([mLat, mLng]);
const isFirst = i === 0;
const isLast = i === locations.length - 1;
const marker = L.circleMarker([lat, lng], {
const marker = L.circleMarker([mLat, mLng], {
radius: isFirst || isLast ? 8 : 4,
fillColor: isFirst ? '#22c55e' : isLast ? '#ef4444' : '#3b82f6',
color: '#fff', weight: 1, fillOpacity: 0.9,
@@ -1466,7 +1897,8 @@
const lng = loc.longitude || loc.lng || loc.lon;
if (!lat || !lng) { showToast('没有有效坐标数据', 'info'); return; }
const marker = L.marker([lat, lng]).addTo(locationMap);
const [mLat, mLng] = toMapCoord(lat, lng);
const marker = L.marker([mLat, mLng]).addTo(locationMap);
marker.bindPopup(`
<b>最新位置</b><br>
类型: ${loc.location_type || '-'}<br>
@@ -1476,7 +1908,7 @@
时间: ${formatTime(loc.recorded_at || loc.created_at)}
`).openPopup();
mapMarkers.push(marker);
locationMap.setView([lat, lng], 15);
locationMap.setView([mLat, mLng], 15);
showToast('已显示最新位置');
} catch (err) {
showToast('获取最新位置失败: ' + err.message, 'error');
@@ -1783,6 +2215,8 @@
}).join('');
}
renderPagination('beaconsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadBeacons');
// Render beacon side panel
renderBeaconPanel(items);
} catch (err) {
showToast('加载信标列表失败: ' + err.message, 'error');
document.getElementById('beaconsTableBody').innerHTML = '<tr><td colspan="8" class="text-center text-red-400 py-8">加载失败</td></tr>';