2026-03-27 10:19:34 +00:00
<!DOCTYPE html>
< html lang = "zh-CN" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > KKS 工牌管理系统< / title >
< script src = "https://cdn.tailwindcss.com" > < / script >
2026-03-27 13:04:11 +00:00
< script src = "https://webapi.amap.com/maps?v=2.0&key=9c2fe56bb2bad44d238dd9b4be249e33&plugin=AMap.MouseTool,AMap.PolygonEditor,AMap.CircleEditor" > < / script >
2026-03-27 10:19:34 +00:00
< script src = "https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js" > < / script >
< link rel = "stylesheet" href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" / >
< style >
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background: #111827; color: #e5e7eb; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #1f2937; }
::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #6b7280; }
.sidebar { width: 240px; min-height: 100vh; background: #1f2937; position: fixed; left: 0; top: 0; z-index: 40; transition: transform 0.3s; }
.main-content { margin-left: 240px; min-height: 100vh; }
.nav-item { display: flex; align-items: center; padding: 12px 20px; color: #9ca3af; cursor: pointer; transition: all 0.2s; border-left: 3px solid transparent; }
.nav-item:hover { background: #374151; color: #e5e7eb; }
.nav-item.active { background: #374151; color: #60a5fa; border-left-color: #60a5fa; }
.nav-item i { width: 24px; margin-right: 12px; text-align: center; }
.page { display: none; }
.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: 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; }
.beacon-search-item:hover { background: #1e3a5f; }
2026-03-27 13:04:11 +00:00
.amap-container { z-index: 0 !important; }
2026-03-27 10:19:34 +00:00
.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; }
.toast.info { background: #2563eb; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
@keyframes countUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.badge { padding: 2px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; }
.badge-online { background: #065f46; color: #6ee7b7; }
.badge-offline { background: #7f1d1d; color: #fca5a5; }
.badge-pending { background: #78350f; color: #fde68a; }
.badge-sent { background: #1e3a5f; color: #93c5fd; }
.badge-success { background: #065f46; color: #6ee7b7; }
.badge-failed { background: #7f1d1d; color: #fca5a5; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 12px 16px; background: #111827; color: #9ca3af; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid #374151; }
td { padding: 12px 16px; border-bottom: 1px solid #1f2937; font-size: 14px; }
tr:hover td { background: #1a2332; }
.btn { padding: 8px 16px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; border: none; transition: all 0.2s; display: inline-flex; align-items: center; gap: 6px; }
.btn-primary { background: #2563eb; color: white; }
.btn-primary:hover { background: #1d4ed8; }
.btn-danger { background: #dc2626; color: white; }
.btn-danger:hover { background: #b91c1c; }
.btn-success { background: #059669; color: white; }
.btn-success:hover { background: #047857; }
.btn-secondary { background: #4b5563; color: white; }
.btn-secondary:hover { background: #374151; }
.btn-warning { background: #d97706; color: white; }
.btn-warning:hover { background: #b45309; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
input, select, textarea {
background: #111827; border: 1px solid #374151; color: #e5e7eb; padding: 8px 12px;
border-radius: 8px; font-size: 14px; width: 100%; outline: none; transition: border-color 0.2s;
}
select option { background: #1f2937; color: #e5e7eb; }
input:focus, select:focus, textarea:focus { border-color: #2563eb; }
label { display: block; margin-bottom: 4px; font-size: 14px; color: #9ca3af; }
.form-group { margin-bottom: 16px; }
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid #374151; border-top-color: #60a5fa; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-overlay { position: absolute; inset: 0; background: rgba(17,24,39,0.7); display: flex; align-items: center; justify-content: center; z-index: 10; border-radius: 12px; }
.pagination { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 16px; }
.pagination button { padding: 6px 12px; background: #374151; color: #e5e7eb; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
.pagination button:hover { background: #4b5563; }
.pagination button.active { background: #2563eb; }
.pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
2026-03-27 13:04:11 +00:00
.amap-container { background: #111827; }
2026-03-27 10:19:34 +00:00
.alarm-sos { color: #ef4444; }
.alarm-low_battery { color: #f97316; }
.alarm-remove { color: #eab308; }
.alarm-fence { color: #a855f7; }
.alarm-default { color: #6b7280; }
/* Help Guide */
.page-guide { background: linear-gradient(135deg, #1e293b 0%, #1a2332 100%); border: 1px solid #334155; border-radius: 12px; margin-bottom: 20px; overflow: hidden; transition: all 0.3s ease; }
.page-guide.collapsed .guide-body { display: none; }
.guide-header { display: flex; align-items: center; justify-content: between; padding: 12px 18px; cursor: pointer; user-select: none; }
.guide-header:hover { background: rgba(59,130,246,0.05); }
.guide-header .guide-icon { width: 32px; height: 32px; background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%); border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-right: 12px; flex-shrink: 0; }
.guide-header .guide-title { flex: 1; font-size: 14px; font-weight: 600; color: #94a3b8; }
.guide-header .guide-toggle { color: #64748b; font-size: 12px; transition: transform 0.3s; padding: 4px 8px; }
.page-guide.collapsed .guide-toggle { transform: rotate(-90deg); }
.guide-body { padding: 0 18px 16px 18px; }
.guide-steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
.guide-step { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; background: rgba(30,41,59,0.6); border-radius: 8px; border: 1px solid #1e293b; }
.guide-step .step-num { width: 22px; height: 22px; background: #3b82f6; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: white; flex-shrink: 0; margin-top: 1px; }
.guide-step .step-text { font-size: 13px; color: #94a3b8; line-height: 1.5; }
.guide-step .step-text strong { color: #e2e8f0; font-weight: 600; }
.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; }
2026-03-30 04:26:29 +00:00
.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); }
2026-03-27 10:19:34 +00:00
.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; } }
2026-03-31 09:41:09 +00:00
.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; }
2026-03-27 10:19:34 +00:00
< / style >
< / head >
< body >
<!-- Toast Container -->
< div id = "toastContainer" class = "toast-container" > < / div >
<!-- Sidebar -->
< aside class = "sidebar" id = "sidebar" >
< div class = "p-5 border-b border-gray-700" >
< h1 class = "text-xl font-bold text-blue-400" > < i class = "fas fa-id-badge mr-2" > < / i > KKS 工牌管理< / h1 >
< p class = "text-xs text-gray-500 mt-1" > Badge Management System< / p >
< / div >
< nav class = "mt-4" >
< div class = "nav-item active" data-page = "dashboard" onclick = "navigateTo('dashboard')" >
< i class = "fas fa-tachometer-alt" > < / i > < span > 仪表盘< / span >
< / div >
< div class = "nav-item" data-page = "devices" onclick = "navigateTo('devices')" >
< i class = "fas fa-microchip" > < / i > < span > 设备管理< / span >
< / div >
< div class = "nav-item" data-page = "locations" onclick = "navigateTo('locations')" >
< i class = "fas fa-map-marker-alt" > < / i > < span > 位置追踪< / span >
< / div >
< div class = "nav-item" data-page = "alarms" onclick = "navigateTo('alarms')" >
< i class = "fas fa-bell" > < / i > < span > 告警管理< / span >
< / div >
< div class = "nav-item" data-page = "attendance" onclick = "navigateTo('attendance')" >
< i class = "fas fa-clipboard-check" > < / i > < span > 考勤记录< / span >
< / div >
< div class = "nav-item" data-page = "bluetooth" onclick = "navigateTo('bluetooth')" >
< i class = "fab fa-bluetooth-b" > < / i > < span > 蓝牙记录< / span >
< / div >
< div class = "nav-item" data-page = "beacons" onclick = "navigateTo('beacons')" >
< i class = "fas fa-broadcast-tower" > < / i > < span > 信标管理< / span >
< / div >
2026-03-27 13:04:11 +00:00
< div class = "nav-item" data-page = "fences" onclick = "navigateTo('fences')" >
< i class = "fas fa-draw-polygon" > < / i > < span > 围栏管理< / span >
< / div >
< div class = "nav-item" data-page = "datalog" onclick = "navigateTo('datalog')" >
< i class = "fas fa-database" > < / i > < span > 数据日志< / span >
< / div >
2026-03-27 10:19:34 +00:00
< div class = "nav-item" data-page = "commands" onclick = "navigateTo('commands')" >
< i class = "fas fa-terminal" > < / i > < span > 指令管理< / span >
< / div >
< / nav >
< div class = "absolute bottom-0 left-0 right-0 p-4 border-t border-gray-700" >
< div class = "flex items-center gap-3" >
< div class = "w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-sm font-bold" > A< / div >
< div >
< div class = "text-sm font-medium" > 管理员< / div >
< div class = "text-xs text-gray-500" > Administrator< / div >
< / div >
< / div >
< / div >
< / aside >
<!-- Main Content -->
< main class = "main-content" >
<!-- Top Bar -->
< header class = "bg-gray-800 border-b border-gray-700 px-6 py-3 flex items-center justify-between sticky top-0 z-30" >
< div class = "flex items-center gap-4" >
< h2 id = "pageTitle" class = "text-lg font-semibold" > 仪表盘< / h2 >
< / div >
< div class = "flex items-center gap-4" >
< span id = "healthStatus" class = "text-sm text-gray-400" > < i class = "fas fa-circle text-gray-600 mr-1 text-xs" > < / i > 检查中...< / span >
< span class = "text-sm text-gray-500" id = "currentTime" > < / span >
< / div >
< / header >
< div class = "p-6" >
<!-- ==================== DASHBOARD PAGE ==================== -->
< div id = "page-dashboard" class = "page active" >
< 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 >
< span class = "guide-title" > 仪表盘 — 系统全局概览< / span >
< i class = "fas fa-chevron-down guide-toggle" > < / i >
< / div >
< div class = "guide-body" >
< div class = "guide-steps" >
< div class = "guide-step" > < div class = "step-num" > 1< / div > < div class = "step-text" > < strong > 设备状态< / strong > :实时显示设备总数、在线/离线数量及 TCP 连接数< / 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" > 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 > :滚动列表显示最新 10 条告警事件< / div > < / div >
< / div >
< div class = "guide-tips" > < p > < i class = "fas fa-sync-alt" > < / i > 数据每 30 秒自动刷新,无需手动操作< / p > < / div >
< / div >
< / div >
2026-03-31 05:01:04 +00:00
< div class = "flex items-center justify-between mb-4" >
< div class = "flex items-center gap-3" >
< span class = "text-xs text-gray-500" id = "dashLastUpdated" > < / span >
2026-03-27 10:19:34 +00:00
< / div >
2026-04-01 07:06:37 +00:00
< div class = "flex items-center gap-2" >
< button class = "btn btn-sm" style = "padding:4px 12px;font-size:12px;" onclick = "showSystemConfigModal()" > < i class = "fas fa-cog mr-1" > < / i > 系统配置< / button >
< button class = "btn btn-sm" style = "padding:4px 12px;font-size:12px;" onclick = "showAuditLogModal()" > < i class = "fas fa-history mr-1" > < / i > 审计日志< / button >
< button class = "btn btn-sm" style = "padding:4px 12px;font-size:12px;" onclick = "showBackupModal()" > < i class = "fas fa-database mr-1" > < / i > 备份管理< / button >
< button class = "btn btn-sm" style = "padding:4px 12px;font-size:12px;" onclick = "showFirmwareModal()" > < i class = "fas fa-microchip mr-1" > < / i > 固件信息< / button >
< button class = "btn btn-sm" style = "padding:4px 12px;font-size:12px;" onclick = "loadDashboard()" > < i class = "fas fa-sync-alt mr-1" > < / i > 刷新< / button >
< / div >
2026-03-31 05:01:04 +00:00
< / div >
< div class = "grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6" >
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-microchip text-blue-400 mr-1" > < / i > 设备总数< / p >
< p class = "text-2xl font-bold" id = "dashTotalDevices" > -< / p >
2026-03-27 10:19:34 +00:00
< / div >
2026-03-31 05:01:04 +00:00
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-wifi text-green-400 mr-1" > < / i > 在线< / p >
< p class = "text-2xl font-bold text-green-400" id = "dashOnlineDevices" > -< / p >
2026-03-27 10:19:34 +00:00
< / div >
2026-03-31 05:01:04 +00:00
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-plug text-red-400 mr-1" > < / i > 离线< / p >
< p class = "text-2xl font-bold text-red-400" id = "dashOfflineDevices" > -< / p >
2026-03-27 10:19:34 +00:00
< / div >
2026-03-31 05:01:04 +00:00
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-bell text-yellow-400 mr-1" > < / i > 告警总数< / p >
< p class = "text-2xl font-bold text-yellow-400" id = "dashTotalAlarms" > -< / p >
2026-03-27 10:19:34 +00:00
< / div >
2026-03-31 05:01:04 +00:00
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-exclamation-triangle text-orange-400 mr-1" > < / i > 未确认< / p >
< p class = "text-2xl font-bold text-orange-400" id = "dashUnackAlarms" > -< / p >
2026-03-27 10:19:34 +00:00
< / div >
2026-03-31 05:01:04 +00:00
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-heartbeat text-green-400 mr-1" > < / i > 系统< / p >
< p class = "text-lg font-bold text-green-400 mt-1" id = "dashSystemStatus" > -< / p >
< span class = "text-xs text-gray-500" id = "dashConnectedHint" > < / span >
2026-03-27 10:19:34 +00:00
< / div >
< / div >
2026-03-31 10:11:33 +00:00
< div class = "grid grid-cols-2 md:grid-cols-4 gap-4 mb-6" >
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-bell text-red-400 mr-1" > < / i > 今日告警< / p >
< p class = "text-2xl font-bold text-red-400" id = "dashTodayAlarms" > -< / p >
< / div >
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-user-check text-cyan-400 mr-1" > < / i > 今日考勤< / p >
< p class = "text-2xl font-bold text-cyan-400" id = "dashTodayAttendance" > -< / p >
< / div >
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-map-pin text-blue-400 mr-1" > < / i > 今日定位< / p >
< p class = "text-2xl font-bold text-blue-400" id = "dashTodayLocations" > -< / p >
< / div >
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-percentage text-green-400 mr-1" > < / i > 在线率< / p >
< p class = "text-2xl font-bold text-green-400" id = "dashOnlineRate" > -< / p >
< / div >
< / div >
2026-03-27 10:19:34 +00:00
< div class = "grid grid-cols-1 lg:grid-cols-2 gap-6" >
< div class = "stat-card" >
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-chart-pie mr-2 text-blue-400" > < / i > 告警类型分布< / h3 >
< div style = "max-height: 300px; display: flex; justify-content: center;" >
< canvas id = "dashAlarmChart" > < / canvas >
< / div >
< / div >
< div class = "stat-card" >
2026-03-31 05:01:04 +00:00
< div class = "flex items-center justify-between mb-4" >
< h3 class = "text-lg font-semibold" > < i class = "fas fa-list mr-2 text-red-400" > < / i > 最近告警< / h3 >
< a href = "#" onclick = "event.preventDefault();switchPage('alarms')" class = "text-xs text-blue-400 hover:text-blue-300" > 查看全部 < i class = "fas fa-arrow-right ml-1" > < / i > < / a >
< / div >
2026-03-27 10:19:34 +00:00
< div id = "dashRecentAlarms" class = "space-y-2" style = "max-height: 300px; overflow-y: auto;" >
< p class = "text-gray-500 text-sm" > 加载中...< / p >
< / div >
< / div >
< / div >
< / div >
<!-- ==================== DEVICES PAGE ==================== -->
< div id = "page-devices" class = "page" >
< 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 >
< span class = "guide-title" > 设备管理 — 注册、监控与维护工牌设备< / span >
< i class = "fas fa-chevron-down guide-toggle" > < / i >
< / div >
< div class = "guide-body" >
< div class = "guide-steps" >
< div class = "guide-step" > < div class = "step-num" > 1< / div > < div class = "step-text" > < strong > 搜索设备< / strong > :在搜索框输入 IMEI 或设备名称,按 Enter 检索< / 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" > 3< / div > < div class = "step-text" > < strong > 添加设备< / strong > :点击右上角蓝色按钮,填写 IMEI(15-20位) 和设备类型< / div > < / div >
< div class = "guide-step" > < div class = "step-num" > 4< / div > < div class = "step-text" > < strong > 查看详情< / strong > :点击表格行查看设备详细信息,支持编辑和删除< / div > < / div >
< / div >
< div class = "guide-tips" > < p > < i class = "fas fa-info-circle" > < / i > 设备首次通过 TCP 连接登录时会自动注册,也可手动添加预注册< / p > < / div >
< / div >
< / div >
< div class = "flex flex-wrap items-center justify-between gap-4 mb-6" >
< div class = "flex flex-wrap items-center gap-3" >
< div class = "relative" >
< i class = "fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" > < / i >
< input type = "text" id = "deviceSearch" placeholder = "搜索 IMEI / 名称..." class = "pl-10" style = "width:240px" onkeyup = "if(event.key==='Enter')loadDevices()" >
< / div >
< select id = "deviceStatusFilter" style = "width:140px" onchange = "loadDevices()" >
< option value = "" > 全部状态< / option >
< option value = "online" > 在线< / option >
< option value = "offline" > 离线< / option >
< / select >
< button class = "btn btn-secondary" onclick = "loadDevices()" > < i class = "fas fa-sync-alt" > < / i > 刷新< / button >
2026-04-01 07:06:37 +00:00
< button class = "btn btn-secondary" onclick = "exportCSV('devices')" title = "导出当前筛选条件的设备列表" > < i class = "fas fa-file-csv" > < / i > 导出< / button >
2026-03-31 09:41:09 +00:00
< 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 >
2026-04-01 07:06:37 +00:00
< span style = "width:1px;height:24px;background:#374151;margin:0 4px" > < / span >
< button class = "btn btn-secondary" onclick = "_setupBluetoothMode()" id = "btnBtMode" style = "color:#a855f7;border-color:#a855f7" > < i class = "fas fa-bluetooth-b" > < / i > 蓝牙打卡模式< / button >
< button class = "btn btn-secondary" onclick = "_restoreNormalMode()" style = "color:#22c55e;border-color:#22c55e" > < i class = "fas fa-undo" > < / i > 恢复正常模式< / button >
< / div >
< div class = "flex items-center gap-3" >
< button class = "btn btn-secondary" onclick = "showDeviceGroupsModal()" > < i class = "fas fa-layer-group" > < / i > 分组管理< / button >
< button class = "btn btn-primary" onclick = "showAddDeviceModal()" > < i class = "fas fa-plus" > < / i > 添加设备< / button >
2026-03-27 10:19:34 +00:00
< / div >
< / div >
< div class = "bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" >
< div id = "devicesLoading" class = "loading-overlay" style = "display:none" > < div class = "spinner" > < / div > < / div >
< div class = "overflow-x-auto" >
< table >
< thead >
< tr >
2026-03-31 09:41:09 +00:00
< 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 >
2026-03-27 10:19:34 +00:00
< / tr >
< / thead >
< tbody id = "devicesTableBody" >
2026-03-31 09:41:09 +00:00
< tr > < td colspan = "10" class = "text-center text-gray-500 py-8" > 加载中...< / td > < / tr >
2026-03-27 10:19:34 +00:00
< / tbody >
< / table >
< / div >
< div id = "devicesPagination" class = "pagination p-4" > < / div >
< / div >
< / div >
<!-- ==================== LOCATIONS PAGE ==================== -->
< div id = "page-locations" class = "page" >
2026-03-31 09:41:09 +00:00
<!-- 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" >
2026-03-27 10:19:34 +00:00
< 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 >
< span class = "guide-title" > 位置追踪 — 地图定位与轨迹回放< / span >
< i class = "fas fa-chevron-down guide-toggle" > < / i >
< / div >
< 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 >
2026-03-30 04:26:29 +00:00
< div class = "guide-step" > < div class = "step-num" > 2< / div > < div class = "step-text" > < strong > 最新位置< / strong > : 发送WHERE#指令获取设备实时定位(需设备在线)< / div > < / div >
2026-03-27 10:19:34 +00:00
< 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 >
< div class = "guide-tips" > < p > < i class = "fas fa-map-marked-alt" > < / i > 轨迹以蓝色折线显示,绿色标记为起点,红色标记为终点< / p > < / div >
< / div >
< / div >
< 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" >
2026-03-30 09:41:55 +00:00
< select id = "locDeviceSelect" style = "width:200px" onchange = "onLocDeviceSelectChange(this.value)" >
< option value = "" > 全部设备< / option >
2026-03-27 10:19:34 +00:00
< / 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 >
2026-03-27 13:04:11 +00:00
< button class = "btn btn-primary" onclick = "playTrack()" style = "background:#7c3aed" > < i class = "fas fa-play" > < / i > 路径回放< / button >
2026-03-27 10:19:34 +00:00
< button class = "btn btn-success" onclick = "loadLatestPosition()" > < i class = "fas fa-crosshairs" > < / i > 最新位置< / button >
2026-03-30 09:41:55 +00:00
< button id = "btnHideLowPrecision" class = "btn btn-secondary" onclick = "toggleHideLowPrecision()" title = "隐藏 LBS/WiFi 低精度定位点,仅显示 GPS" > < i class = "fas fa-eye" > < / i > 低精度< / button >
2026-03-27 10:19:34 +00:00
< button class = "btn btn-secondary" onclick = "loadLocationRecords()" > < i class = "fas fa-list" > < / i > 查询记录< / button >
2026-04-01 07:06:37 +00:00
< button class = "btn btn-secondary" onclick = "exportCSV('locations')" title = "导出当前筛选条件的位置记录" > < i class = "fas fa-file-csv" > < / i > 导出< / button >
2026-04-01 09:34:42 +00:00
2026-03-30 04:26:29 +00:00
< button class = "btn" style = "background:#dc2626;color:#fff" onclick = "batchDeleteNoCoordLocations()" > < i class = "fas fa-broom" > < / i > 清除无坐标< / button >
2026-04-01 07:06:37 +00:00
< button class = "btn" style = "background:#f59e0b;color:#000" onclick = "showLocationCleanupModal()" title = "清理N天前的旧记录" > < i class = "fas fa-trash-clock" > < / i > 清理旧数据< / button >
2026-03-30 04:26:29 +00:00
< 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 >
2026-03-27 10:19:34 +00:00
< / div >
2026-03-30 04:26:29 +00:00
< div class = "bg-gray-800 rounded-xl border border-gray-700 overflow-hidden mb-6" style = "height: 500px; position: relative;" >
2026-03-27 10:19:34 +00:00
< div id = "locationMap" style = "height: 100%; width: 100%;" > < / div >
2026-03-30 04:26:29 +00:00
< 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" > ● < / span > GPS
< span style = "color:#06b6d4;margin-left:8px" > ● < / span > WiFi < span style = "color:#9ca3af" > (~80m)< / span >
< span style = "color:#f59e0b;margin-left:8px" > ○ < / span > LBS < span style = "color:#9ca3af" > (~1km)< / span >
< span style = "color:#a855f7;margin-left:8px" > ● < / span > 蓝牙
< / div >
2026-03-27 10:19:34 +00:00
< / 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 >
2026-03-31 05:01:04 +00:00
< th style = "width:32px" > < input type = "checkbox" id = "locSelectAll" onchange = "toggleAllLocCheckboxes(this.checked)" title = "全选当前页" > < / th >
2026-03-31 09:41:09 +00:00
< th > IMEI< / th >
2026-03-27 10:19:34 +00:00
< th > 类型< / th >
< th > 纬度< / th >
< th > 经度< / th >
< th > 地址< / th >
< th > 速度< / th >
2026-03-27 13:04:11 +00:00
< th > 质量< / th >
2026-03-27 10:19:34 +00:00
< th > 时间< / th >
2026-03-27 13:04:11 +00:00
< th > 操作< / th >
2026-03-27 10:19:34 +00:00
< / tr >
< / thead >
< tbody id = "locationsTableBody" >
2026-03-30 04:26:29 +00:00
< tr > < td colspan = "10" class = "text-center text-gray-500 py-8" > 请选择设备并查询< / td > < / tr >
2026-03-27 10:19:34 +00:00
< / tbody >
< / table >
< / div >
< div id = "locationsPagination" class = "pagination p-4" > < / div >
< / div >
< / div >
< / div >
2026-03-31 09:41:09 +00:00
< / 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 >
2026-04-01 07:06:37 +00:00
< input type = "date" id = "ovTrackStart" class = "form-control" style = "width:140px;padding:4px 8px;font-size:0.85rem" >
< span class = "text-gray-400" > ~< / span >
< input type = "date" id = "ovTrackEnd" class = "form-control" style = "width:140px;padding:4px 8px;font-size:0.85rem" >
2026-03-31 09:41:09 +00:00
< 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 >
2026-03-27 10:19:34 +00:00
< / div >
<!-- ==================== ALARMS PAGE ==================== -->
< div id = "page-alarms" class = "page" >
< 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 >
< span class = "guide-title" > 告警管理 — 查看、处理设备告警事件< / span >
< i class = "fas fa-chevron-down guide-toggle" > < / i >
< / div >
< div class = "guide-body" >
< div class = "guide-steps" >
< div class = "guide-step" > < div class = "step-num" > 1< / div > < div class = "step-text" > < strong > 告警类型< / strong > : SOS 紧急求助、低电量、设备拆除、围栏进出、跌倒等< / 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" > 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 > :顶部展示告警数量统计与类型分布饼图< / div > < / div >
< / div >
< div class = "guide-tips" > < p > < i class = "fas fa-exclamation-circle" > < / i > SOS 告警为最高优先级,建议优先处理未确认的 SOS 事件< / p > < / div >
< / div >
< / div >
< div class = "grid grid-cols-1 md:grid-cols-4 gap-4 mb-6" >
< div class = "stat-card" >
< p class = "text-gray-400 text-sm" > 告警总数< / p >
< p class = "text-2xl font-bold mt-1" id = "alarmStatTotal" > -< / p >
2026-03-31 10:11:33 +00:00
< p class = "text-xs text-gray-500 mt-1" id = "alarmStatToday" > < / p >
2026-03-27 10:19:34 +00:00
< / div >
< div class = "stat-card" >
< p class = "text-gray-400 text-sm" > 未确认< / p >
< p class = "text-2xl font-bold mt-1 text-orange-400" id = "alarmStatUnack" > -< / p >
< / div >
< div class = "stat-card" >
< p class = "text-gray-400 text-sm" > 已确认< / p >
< p class = "text-2xl font-bold mt-1 text-green-400" id = "alarmStatAck" > -< / p >
< / div >
< div class = "stat-card" style = "display:flex;align-items:center;justify-content:center;" >
< canvas id = "alarmTypeChart" style = "max-height:120px;" > < / canvas >
< / div >
< / div >
< div class = "flex flex-wrap items-center gap-3 mb-4" >
< select id = "alarmDeviceFilter" style = "width:180px" >
< option value = "" > 全部设备< / option >
< / select >
< select id = "alarmTypeFilter" style = "width:150px" >
< option value = "" > 全部类型< / option >
< option value = "sos" > SOS< / option >
< option value = "low_battery" > 低电量< / option >
< option value = "vibration" > 振动< / option >
< option value = "power_cut" > 断电< / option >
< option value = "remove" > 拆除< / option >
< option value = "enter_fence" > 进入围栏< / option >
< option value = "exit_fence" > 离开围栏< / option >
< option value = "power_on" > 开机< / option >
< option value = "power_off" > 关机< / option >
< option value = "voice_alarm" > 声控报警< / option >
< option value = "fake_base_station" > 伪基站< / option >
< / select >
< select id = "alarmAckFilter" style = "width:150px" >
< option value = "" > 全部状态< / option >
< option value = "false" > 未确认< / option >
< option value = "true" > 已确认< / option >
< / select >
< input type = "date" id = "alarmStartDate" style = "width:160px" >
< input type = "date" id = "alarmEndDate" style = "width:160px" >
< button class = "btn btn-primary" onclick = "loadAlarms()" > < i class = "fas fa-search" > < / i > 查询< / button >
< button class = "btn btn-secondary" onclick = "loadAlarms()" > < i class = "fas fa-sync-alt" > < / i > 刷新< / button >
2026-04-01 07:06:37 +00:00
< button class = "btn btn-secondary" onclick = "exportCSV('alarms')" title = "导出当前筛选条件的告警记录" > < i class = "fas fa-file-csv" > < / i > 导出< / button >
2026-03-31 02:03:21 +00:00
< button class = "btn" style = "background:#b91c1c;color:#fff" id = "btnBatchDeleteAlarm" onclick = "batchDeleteSelectedAlarms()" disabled > < i class = "fas fa-trash-alt" > < / i > 删除选中 (< span id = "alarmSelCount" > 0< / span > )< / button >
2026-03-31 10:11:33 +00:00
< button class = "btn btn-success" id = "btnBatchAckAlarm" onclick = "batchAcknowledgeAlarms()" disabled > < i class = "fas fa-check-double" > < / i > 批量确认 (< span id = "alarmAckCount" > 0< / span > )< / button >
2026-04-01 07:06:37 +00:00
< button class = "btn" style = "background:#dc2626;color:#fff" onclick = "showAlarmCleanupModal()" title = "按条件批量删除告警" > < i class = "fas fa-broom" > < / i > 条件删除< / button >
< button class = "btn btn-secondary" onclick = "showAlertRulesModal()" title = "管理告警规则" > < i class = "fas fa-cog" > < / i > 告警规则< / button >
2026-03-27 10:19:34 +00:00
< / div >
< div class = "bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" >
< div id = "alarmsLoading" class = "loading-overlay" style = "display:none" > < div class = "spinner" > < / div > < / div >
< div class = "overflow-x-auto" >
< table >
< thead >
< tr >
2026-03-31 10:11:33 +00:00
< th style = "width:32px" > < input type = "checkbox" id = "alarmSelectAll" onchange = "toggleAllCheckboxes('alarm-sel-cb','alarmSelCount','btnBatchDeleteAlarm',this.checked);updateSelCount('alarm-sel-cb','alarmAckCount','btnBatchAckAlarm')" title = "全选当前页" > < / th >
2026-03-31 09:41:09 +00:00
< th > IMEI< / th >
2026-03-27 10:19:34 +00:00
< th > 类型< / th >
< th > 来源< / th >
< th > 位置< / th >
< th > 电量< / th >
< th > 信号< / th >
< th > 状态< / th >
< th > 时间< / th >
< th > 操作< / th >
< / tr >
< / thead >
< tbody id = "alarmsTableBody" >
2026-03-31 02:03:21 +00:00
< tr > < td colspan = "10" class = "text-center text-gray-500 py-8" > 加载中...< / td > < / tr >
2026-03-27 10:19:34 +00:00
< / tbody >
< / table >
< / div >
< div id = "alarmsPagination" class = "pagination p-4" > < / div >
< / div >
< / div >
<!-- ==================== ATTENDANCE PAGE ==================== -->
< div id = "page-attendance" class = "page" >
< 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 >
< span class = "guide-title" > 考勤记录 — 工牌打卡签到与统计< / span >
< i class = "fas fa-chevron-down guide-toggle" > < / i >
< / div >
< div class = "guide-body" >
< div class = "guide-steps" >
< div class = "guide-step" > < div class = "step-num" > 1< / div > < div class = "step-text" > < strong > GPS考勤 (0xB0/0xB1)< / strong > :工牌到达打卡区域时通过 GPS+基站+WiFi 综合定位自动打卡,记录精确位置和地址< / div > < / div >
< div class = "guide-step" > < div class = "step-num" > 2< / div > < div class = "step-text" > < strong > 签到/签退< / strong > :系统根据终端状态码自动判断 clock_in(签到) 或 clock_out(签退)< / div > < / div >
< div class = "guide-step" > < div class = "step-num" > 3< / div > < div class = "step-text" > < strong > 位置信息< / strong > : GPS 定位显示卫星图标,基站/WiFi 定位显示信号塔图标,自动反向地理编码获取中文地址< / div > < / div >
< div class = "guide-step" > < div class = "step-num" > 4< / div > < div class = "step-text" > < strong > 与蓝牙打卡区别< / strong > : GPS考勤(0xB0/0xB1)记录完整定位数据;蓝牙打卡(0xB2)记录信标信息,请在「蓝牙记录」页面查看< / div > < / div >
< / div >
< div class = "guide-tips" > < p > < i class = "fas fa-clock" > < / i > 考勤数据由工牌设备自动上报,包含 GPS 坐标、基站、WiFi AP、电量、信号强度等完整信息< / p > < / div >
< / div >
< / div >
< div class = "grid grid-cols-1 md:grid-cols-4 gap-4 mb-6" >
< div class = "stat-card" >
< p class = "text-gray-400 text-sm" > 总记录数< / p >
< p class = "text-2xl font-bold mt-1" id = "attStatTotal" > -< / p >
2026-03-31 10:11:33 +00:00
< p class = "text-xs text-gray-500 mt-1" id = "attStatToday" > < / p >
2026-03-27 10:19:34 +00:00
< / div >
< div class = "stat-card" >
< p class = "text-gray-400 text-sm" > 签到次数< / p >
< p class = "text-2xl font-bold mt-1 text-green-400" id = "attStatCheckIn" > -< / p >
< / div >
< div class = "stat-card" >
< p class = "text-gray-400 text-sm" > 签退次数< / p >
< p class = "text-2xl font-bold mt-1 text-blue-400" id = "attStatCheckOut" > -< / p >
< / div >
< div class = "stat-card" >
< p class = "text-gray-400 text-sm" > 其他< / p >
< p class = "text-2xl font-bold mt-1 text-gray-400" id = "attStatOther" > -< / p >
< / div >
< / div >
< div class = "flex flex-wrap items-center gap-3 mb-4" >
< select id = "attDeviceFilter" style = "width:180px" >
< option value = "" > 全部设备< / option >
< / select >
< select id = "attTypeFilter" style = "width:150px" >
< option value = "" > 全部类型< / option >
< option value = "clock_in" > 签到< / option >
< option value = "clock_out" > 签退< / option >
< / select >
2026-03-30 04:26:29 +00:00
< select id = "attSourceFilter" style = "width:150px" >
< option value = "" > 全部来源< / option >
< option value = "device" > 设备打卡< / option >
< option value = "bluetooth" > 蓝牙打卡< / option >
< option value = "fence" > 围栏自动< / option >
< / select >
2026-03-27 10:19:34 +00:00
< 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 >
< button class = "btn btn-secondary" onclick = "loadAttendance()" > < i class = "fas fa-sync-alt" > < / i > 刷新< / button >
2026-04-01 07:06:37 +00:00
< button class = "btn btn-secondary" onclick = "exportCSV('attendance')" title = "导出当前筛选条件的考勤记录" > < i class = "fas fa-file-csv" > < / i > 导出< / button >
2026-03-31 02:03:21 +00:00
< button class = "btn" style = "background:#b91c1c;color:#fff" id = "btnBatchDeleteAtt" onclick = "batchDeleteSelectedAttendance()" disabled > < i class = "fas fa-trash-alt" > < / i > 删除选中 (< span id = "attSelCount" > 0< / span > )< / button >
2026-03-27 10:19:34 +00:00
< / div >
< div class = "bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" >
< div id = "attendanceLoading" class = "loading-overlay" style = "display:none" > < div class = "spinner" > < / div > < / div >
< div class = "overflow-x-auto" >
< table >
< thead >
< tr >
2026-03-31 05:01:04 +00:00
< th style = "width:32px" > < input type = "checkbox" id = "attSelectAll" onchange = "toggleAllCheckboxes('att-sel-cb','attSelCount','btnBatchDeleteAtt',this.checked)" title = "全选当前页" > < / th >
2026-03-31 09:41:09 +00:00
< th > IMEI< / th >
2026-03-27 10:19:34 +00:00
< th > 类型< / th >
2026-03-30 04:26:29 +00:00
< th > 来源< / th >
2026-03-27 10:19:34 +00:00
< th > 位置< / th >
< th > 电量/信号< / th >
< th > 基站< / th >
< th > 时间< / th >
2026-03-30 09:41:55 +00:00
< th > 操作< / th >
2026-03-27 10:19:34 +00:00
< / tr >
< / thead >
< tbody id = "attendanceTableBody" >
2026-03-31 02:03:21 +00:00
< tr > < td colspan = "9" class = "text-center text-gray-500 py-8" > 加载中...< / td > < / tr >
2026-03-27 10:19:34 +00:00
< / tbody >
< / table >
< / div >
< div id = "attendancePagination" class = "pagination p-4" > < / div >
< / div >
< / div >
<!-- ==================== BLUETOOTH PAGE ==================== -->
< div id = "page-bluetooth" class = "page" >
< 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 >
< span class = "guide-title" > 蓝牙记录 — 蓝牙打卡与近场定位数据< / span >
< i class = "fas fa-chevron-down guide-toggle" > < / i >
< / div >
< div class = "guide-body" >
< div class = "guide-steps" >
< div class = "guide-step" > < div class = "step-num" > 1< / div > < div class = "step-text" > < strong > 蓝牙打卡 (0xB2)< / strong > :工牌靠近 iBeacon 信标时自动触发打卡,记录信标 MAC/UUID/Major/Minor、RSSI 信号强度、信标电量、签到/签退类型< / div > < / div >
< div class = "guide-step" > < div class = "step-num" > 2< / div > < div class = "step-text" > < strong > 蓝牙定位 (0xB3)< / strong > :工牌定时扫描周围所有 iBeacon 信标,上报各信标的 RSSI 值用于室内三角定位。一次可上报多个信标数据< / div > < / div >
< div class = "guide-step" > < div class = "step-num" > 3< / div > < div class = "step-text" > < strong > 信标部署< / strong > :需在打卡点/定位区域部署 iBeacon 信标硬件(如 KKM KBeacon 系列),使用信标厂商 APP 配置 UUID、Major、Minor 等参数< / div > < / div >
< div class = "guide-step" > < div class = "step-num" > 4< / div > < div class = "step-text" > < strong > 数据解读< / strong > : RSSI 越接近 0 信号越强(-40dBm = 很近,-80dBm = 较远);信标电量显示为电压(V)或百分比(%)< / div > < / div >
< / div >
< div class = "guide-tips" >
< p > < i class = "fab fa-bluetooth-b" > < / i > < strong > 如何添加蓝牙信标?< / strong > < / p >
< p style = "margin:6px 0 0 0;line-height:1.8;color:#9ca3af;" >
< strong > 第一步< / strong > :购买 iBeacon 兼容的蓝牙信标硬件( 推荐: KKM KBeacon、天工测控 SKYLAB 系列等),覆盖范围通常 10-30 米< br >
< strong > 第二步< / strong > :使用信标厂商提供的 APP( 如 KBeaconPro) 配置信标参数: UUID、Major ID、Minor ID、广播间隔( 建议 300-500ms) 、发射功率< br >
< strong > 第三步< / strong > :将信标安装在打卡点或需要定位的区域(门口、走廊、会议室等),建议距地面 2-3 米< br >
< strong > 第四步< / strong > : P241 工牌会自动扫描附近信标并上报数据,打卡数据通过 0xB2 协议上传,定位数据通过 0xB3 协议上传< br >
< strong > 注意< / strong > :不同信标的 UUID/Major/Minor 应设置为不同值以区分位置;同一区域建议部署 3+ 个信标以实现三角定位
< / p >
< / div >
< / div >
< / div >
2026-03-31 10:11:33 +00:00
< div class = "grid grid-cols-2 md:grid-cols-4 gap-4 mb-4" >
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fab fa-bluetooth-b text-blue-400 mr-1" > < / i > 总记录< / p >
< p class = "text-2xl font-bold" id = "btStatTotal" > -< / p >
< / div >
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-fingerprint text-purple-400 mr-1" > < / i > 打卡< / p >
< p class = "text-2xl font-bold text-purple-400" id = "btStatPunch" > -< / p >
< / div >
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-map-marker-alt text-cyan-400 mr-1" > < / i > 定位< / p >
< p class = "text-2xl font-bold text-cyan-400" id = "btStatLocation" > -< / p >
< / div >
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-broadcast-tower text-green-400 mr-1" > < / i > 信标数< / p >
< p class = "text-2xl font-bold text-green-400" id = "btStatBeacons" > -< / p >
< / div >
< / div >
2026-03-27 10:19:34 +00:00
< div class = "flex flex-wrap items-center gap-3 mb-4" >
< select id = "btDeviceFilter" style = "width:180px" >
< option value = "" > 全部设备< / option >
< / select >
< select id = "btTypeFilter" style = "width:150px" >
< option value = "" > 全部类型< / option >
< option value = "punch" > 打卡< / option >
< option value = "location" > 定位< / option >
< / select >
< input type = "date" id = "btStartDate" style = "width:160px" >
< input type = "date" id = "btEndDate" style = "width:160px" >
< button class = "btn btn-primary" onclick = "loadBluetooth()" > < i class = "fas fa-search" > < / i > 查询< / button >
< button class = "btn btn-secondary" onclick = "loadBluetooth()" > < i class = "fas fa-sync-alt" > < / i > 刷新< / button >
2026-04-01 07:06:37 +00:00
< button class = "btn btn-secondary" onclick = "exportCSV('bluetooth')" title = "导出当前筛选条件的蓝牙记录" > < i class = "fas fa-file-csv" > < / i > 导出< / button >
2026-03-31 02:03:21 +00:00
< button class = "btn" style = "background:#b91c1c;color:#fff" id = "btnBatchDeleteBt" onclick = "batchDeleteSelectedBluetooth()" disabled > < i class = "fas fa-trash-alt" > < / i > 删除选中 (< span id = "btSelCount" > 0< / span > )< / button >
2026-03-27 10:19:34 +00:00
< / div >
< div class = "bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" >
< div id = "bluetoothLoading" class = "loading-overlay" style = "display:none" > < div class = "spinner" > < / div > < / div >
< div class = "overflow-x-auto" >
< table >
< thead >
< tr >
2026-03-31 05:01:04 +00:00
< th style = "width:32px" > < input type = "checkbox" id = "btSelectAll" onchange = "toggleAllCheckboxes('bt-sel-cb','btSelCount','btnBatchDeleteBt',this.checked)" title = "全选当前页" > < / th >
2026-03-31 09:41:09 +00:00
< th > IMEI< / th >
2026-03-27 10:19:34 +00:00
< th > 类型< / th >
< th > 信标MAC< / th >
< th > UUID / Major / Minor< / th >
< th > RSSI< / th >
< th > 信标电量< / th >
< th > 打卡< / th >
< th > 时间< / th >
< / tr >
< / thead >
< tbody id = "bluetoothTableBody" >
2026-03-31 02:03:21 +00:00
< tr > < td colspan = "9" class = "text-center text-gray-500 py-8" > 加载中...< / td > < / tr >
2026-03-27 10:19:34 +00:00
< / tbody >
< / table >
< / div >
< div id = "bluetoothPagination" class = "pagination p-4" > < / div >
< / div >
< / div >
<!-- ==================== BEACONS PAGE ==================== -->
< div id = "page-beacons" class = "page" >
< 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 >
< span class = "guide-title" > 信标管理 — 蓝牙信标注册与位置配置< / span >
< i class = "fas fa-chevron-down guide-toggle" > < / i >
< / div >
< div class = "guide-body" >
< div class = "guide-steps" >
< div class = "guide-step" > < div class = "step-num" > 1< / div > < div class = "step-text" > < strong > 注册信标< / strong > :在此页面添加已部署的 iBeacon 蓝牙信标,填写 MAC 地址、UUID、Major、Minor 等参数< / 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" > 3< / div > < div class = "step-text" > < strong > 自动关联< / strong > :当工牌上报蓝牙数据(0xB2/0xB3)时,系统自动根据信标 MAC 匹配已注册信标的位置信息< / div > < / div >
< div class = "guide-step" > < div class = "step-num" > 4< / div > < div class = "step-text" > < strong > 室内定位< / strong > :多信标场景下,系统取 RSSI 信号最强的已注册信标位置作为工牌当前位置< / div > < / div >
< / div >
< div class = "guide-tips" > < p > < i class = "fas fa-info-circle" > < / i > 信标 MAC 地址格式: AA:BB:CC:DD:EE:FF( 冒号分隔, 大写十六进制) , 可从信标厂商 APP 或 0xB2/0xB3 蓝牙记录中获取< / p > < / div >
< / div >
< / div >
< 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 -->
2026-04-01 07:06:37 +00:00
< div class = "page-main-content" style = "display:flex;flex-direction:column;flex:1;overflow:hidden" >
2026-03-27 10:19:34 +00:00
< button class = "panel-expand-btn" onclick = "toggleSidePanel('beaconSidePanel')" title = "展开信标面板" > < i class = "fas fa-chevron-right" > < / i > < / button >
2026-04-01 07:06:37 +00:00
<!-- Top Tabs -->
< div style = "display:flex;border-bottom:1px solid #374151;background:#1f2937;border-radius:8px 8px 0 0;margin-bottom:12px" >
< button id = "beaconTabList" class = "fence-tab active" onclick = "switchBeaconTab('list')" > < i class = "fas fa-broadcast-tower" > < / i > 信标管理< / button >
< button id = "beaconTabBindings" class = "fence-tab" onclick = "switchBeaconTab('bindings')" > < i class = "fas fa-link" > < / i > 设备绑定< / button >
2026-03-27 10:19:34 +00:00
< / div >
2026-04-01 07:06:37 +00:00
<!-- Tab Content: Beacon List -->
< div id = "beaconTabContentList" style = "display:flex;flex-direction:column;flex:1;overflow:hidden" >
< 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 >
<!-- Tab Content: Device Bindings -->
< div id = "beaconTabContentBindings" style = "display:none;flex-direction:column;flex:1;overflow:hidden" >
< div class = "flex flex-wrap items-center gap-3 mb-3" >
< button class = "btn btn-primary" onclick = "loadBeaconBindingMatrix()" > < i class = "fas fa-sync-alt" > < / i > 刷新< / button >
< button class = "btn btn-secondary" onclick = "_bbReverseSync()" id = "bbReverseSyncBtn" > < i class = "fas fa-download" > < / i > 从设备同步< / button >
< span style = "color:#9ca3af;font-size:12px" > < i class = "fas fa-info-circle" > < / i > 勾选表示绑定设备到信标,取消勾选自动解绑< / span >
< div style = "flex:1" > < / div >
< button id = "beaconBindSaveBtn" class = "btn btn-primary" onclick = "saveBeaconBindingMatrix()" > < i class = "fas fa-save" > < / i > 保存更改< / button >
< / div >
< div class = "bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" style = "flex:1;overflow:auto" >
< div class = "overflow-x-auto" style = "height:100%;overflow-y:auto" >
< table id = "beaconBindMatrix" style = "font-size:12px" >
< thead id = "beaconBindMatrixHead" style = "position:sticky;top:0;background:#1f2937;z-index:1" > < / thead >
< tbody id = "beaconBindMatrixBody" >
< tr > < td class = "text-center text-gray-500 py-4" > 加载中...< / td > < / tr >
< / tbody >
< / table >
< / div >
2026-03-27 10:19:34 +00:00
< / div >
< / div >
< / div >
< / div >
< / div >
2026-03-27 13:04:11 +00:00
<!-- ==================== FENCES PAGE ==================== -->
< div id = "page-fences" class = "page" >
< div class = "page-with-panel" >
<!-- Left: Fence Panel -->
< div class = "side-panel" id = "fenceSidePanel" >
< div class = "panel-header" >
< i class = "fas fa-draw-polygon text-green-400" style = "font-size:14px" > < / i >
< span class = "panel-title" > 围栏列表< / span >
< span id = "fencePanelCount" style = "font-size:11px;color:#6b7280" > < / span >
< button class = "panel-toggle-btn" onclick = "toggleSidePanel('fenceSidePanel')" 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 = "fencePanelSearch" placeholder = "搜索围栏..." oninput = "filterPanelItems('fences')" > < / div > < / div >
< div style = "padding:6px 10px;display:flex;gap:4px;flex-wrap:wrap" >
< button class = "btn" style = "font-size:11px;padding:3px 8px;background:#065f46;border:none;color:#6ee7b7;border-radius:4px" onclick = "toggleAllFencesVisible(true)" > < i class = "fas fa-eye" > < / i > 全部显示< / button >
< button class = "btn" style = "font-size:11px;padding:3px 8px;background:#7f1d1d;border:none;color:#fca5a5;border-radius:4px" onclick = "toggleAllFencesVisible(false)" > < i class = "fas fa-eye-slash" > < / i > 全部隐藏< / button >
< / div >
< div class = "panel-list" id = "fencePanelList" >
< 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 = "fencePanelFooter" > 加载中...< / div >
< / div >
<!-- Right: Main Content -->
< div class = "page-main-content" style = "display:flex;flex-direction:column;flex:1;overflow:hidden" >
< button class = "panel-expand-btn" onclick = "toggleSidePanel('fenceSidePanel')" title = "展开围栏面板" > < i class = "fas fa-chevron-right" > < / i > < / button >
2026-03-30 09:41:55 +00:00
<!-- Top Tabs -->
< div style = "display:flex;border-bottom:1px solid #374151;background:#1f2937;border-radius:8px 8px 0 0;margin-bottom:12px" >
< button id = "fenceTabList" class = "fence-tab active" onclick = "switchFenceTab('list')" > < i class = "fas fa-map-marked-alt" > < / i > 围栏管理< / button >
< button id = "fenceTabBindings" class = "fence-tab" onclick = "switchFenceTab('bindings')" > < i class = "fas fa-link" > < / i > 设备绑定< / button >
2026-03-27 13:04:11 +00:00
< / div >
2026-03-30 09:41:55 +00:00
<!-- Tab Content: Fence List + Map -->
< div id = "fenceTabContentList" style = "display:flex;flex-direction:column;flex:1;overflow:hidden" >
2026-03-31 10:11:33 +00:00
< div class = "grid grid-cols-4 gap-3 mb-3" id = "fenceStatsRow" >
< div style = "background:#1f2937;border:1px solid #374151;border-radius:8px;padding:10px 14px;text-align:center" >
< p class = "text-gray-500" style = "font-size:11px" > 总围栏< / p >
< p class = "text-lg font-bold" id = "fenceStatTotal" > -< / p >
< / div >
< div style = "background:#1f2937;border:1px solid #374151;border-radius:8px;padding:10px 14px;text-align:center" >
< p class = "text-gray-500" style = "font-size:11px" > 已启用< / p >
< p class = "text-lg font-bold text-green-400" id = "fenceStatActive" > -< / p >
< / div >
< div style = "background:#1f2937;border:1px solid #374151;border-radius:8px;padding:10px 14px;text-align:center" >
< p class = "text-gray-500" style = "font-size:11px" > 绑定设备< / p >
< p class = "text-lg font-bold text-cyan-400" id = "fenceStatBindings" > -< / p >
< / div >
< div style = "background:#1f2937;border:1px solid #374151;border-radius:8px;padding:10px 14px;text-align:center" >
< p class = "text-gray-500" style = "font-size:11px" > 今日事件< / p >
< p class = "text-lg font-bold text-yellow-400" id = "fenceStatEvents" > -< / p >
< / div >
< / div >
2026-03-30 09:41:55 +00:00
< div class = "flex flex-wrap items-center gap-3 mb-3" >
< button class = "btn btn-primary" onclick = "loadFences()" > < i class = "fas fa-sync-alt" > < / i > 刷新< / button >
< div style = "flex:1;display:flex;gap:6px;max-width:400px" >
< input type = "text" id = "fenceMapSearchInput" placeholder = "搜索地点..." style = "flex:1" onkeydown = "if(event.key==='Enter')searchFenceMapLocation()" >
< button class = "btn btn-secondary" onclick = "searchFenceMapLocation()" > < i class = "fas fa-search" > < / i > < / button >
< / div >
< div style = "flex:1" > < / div >
< button class = "btn btn-primary" onclick = "showAddFenceModal()" > < i class = "fas fa-plus" > < / i > 添加围栏< / button >
2026-03-30 04:26:29 +00:00
< / div >
2026-03-30 09:41:55 +00:00
< div id = "fenceMapContainer" style = "flex:1;min-height:400px;border-radius:12px;border:1px solid #374151;margin-bottom:12px;position:relative;" >
< div id = "fenceMap" style = "height:100%;width:100%;border-radius:12px;" > < / div >
< / div >
< div class = "bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" style = "max-height:300px;overflow-y:auto" >
< div id = "fencesLoading" class = "loading-overlay" style = "display:none" > < div class = "spinner" > < / div > < / div >
2026-03-27 13:04:11 +00:00
< table >
< thead >
< tr >
< th > 名称< / th >
< th > 类型< / th >
< th > 颜色< / th >
< th > 半径/顶点< / th >
< th > 描述< / th >
< th > 状态< / th >
< th > 更新时间< / th >
< th > 操作< / th >
< / tr >
< / thead >
< tbody id = "fencesTableBody" >
< tr > < td colspan = "8" class = "text-center text-gray-500 py-8" > 加载中...< / td > < / tr >
< / tbody >
< / table >
< / div >
2026-03-30 09:41:55 +00:00
< / div >
<!-- Tab Content: Device Bindings -->
< div id = "fenceTabContentBindings" style = "display:none;flex-direction:column;flex:1;overflow:hidden" >
< div class = "flex flex-wrap items-center gap-3 mb-3" >
< button class = "btn btn-primary" onclick = "loadBindingMatrix()" > < i class = "fas fa-sync-alt" > < / i > 刷新< / button >
< span style = "color:#9ca3af;font-size:12px" > < i class = "fas fa-info-circle" > < / i > 勾选表示绑定设备到围栏,取消勾选自动解绑< / span >
< div style = "flex:1" > < / div >
< button id = "fenceBindSaveBtn" class = "btn btn-primary" onclick = "saveBindingMatrix()" > < i class = "fas fa-save" > < / i > 保存更改< / button >
< / div >
< div class = "bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" style = "flex:1;overflow:auto" >
< div id = "fenceBindMatrixWrap" class = "overflow-x-auto" style = "height:100%;overflow-y:auto" >
< table id = "fenceBindMatrix" style = "font-size:12px" >
< thead id = "fenceBindMatrixHead" style = "position:sticky;top:0;background:#1f2937;z-index:1" > < / thead >
< tbody id = "fenceBindMatrixBody" >
< tr > < td class = "text-center text-gray-500 py-4" > 加载中...< / td > < / tr >
2026-03-30 04:26:29 +00:00
< / tbody >
< / table >
< / div >
< / div >
2026-03-27 13:04:11 +00:00
< / div >
< / div >
< / div >
< / div >
<!-- ==================== DATA LOG PAGE ==================== -->
< div id = "page-datalog" class = "page" >
< div class = "flex flex-wrap items-center gap-3 mb-4" >
< select id = "logDeviceFilter" style = "width:160px" > < option value = "" > 全部设备< / option > < / select >
< select id = "logTypeFilter" style = "width:140px" >
< option value = "" > 全部类型< / option >
2026-03-31 05:01:04 +00:00
< option value = "location" selected > 位置< / option >
2026-03-27 13:04:11 +00:00
< option value = "alarm" > 告警< / option >
< option value = "heartbeat" > 心跳< / option >
< option value = "attendance" > 考勤< / option >
< option value = "bluetooth" > 蓝牙< / option >
< / select >
< input type = "date" id = "logStartDate" style = "width:150px" >
< input type = "date" id = "logEndDate" style = "width:150px" >
< button class = "btn btn-primary" onclick = "loadDataLog()" > < i class = "fas fa-search" > < / i > 查询< / button >
< button class = "btn btn-secondary" onclick = "loadDataLog()" > < i class = "fas fa-sync-alt" > < / i > 刷新< / button >
2026-03-31 02:03:21 +00:00
< button class = "btn" style = "background:#b91c1c;color:#fff" id = "btnBatchDeleteLog" onclick = "batchDeleteSelectedDatalog()" disabled > < i class = "fas fa-trash-alt" > < / i > 删除选中 (< span id = "logSelCount" > 0< / span > )< / button >
2026-03-27 13:04:11 +00:00
< / div >
< div class = "grid grid-cols-5 gap-3 mb-4" >
< div class = "stat-card" style = "padding:14px;text-align:center;cursor:pointer" onclick = "document.getElementById('logTypeFilter').value='location';loadDataLog()" >
< div style = "font-size:22px;font-weight:700;color:#3b82f6" id = "logCountLoc" > -< / div >
< div style = "font-size:12px;color:#9ca3af" > 位置记录< / div >
< / div >
< div class = "stat-card" style = "padding:14px;text-align:center;cursor:pointer" onclick = "document.getElementById('logTypeFilter').value='alarm';loadDataLog()" >
< div style = "font-size:22px;font-weight:700;color:#ef4444" id = "logCountAlarm" > -< / div >
< div style = "font-size:12px;color:#9ca3af" > 告警记录< / div >
< / div >
< div class = "stat-card" style = "padding:14px;text-align:center;cursor:pointer" onclick = "document.getElementById('logTypeFilter').value='heartbeat';loadDataLog()" >
< div style = "font-size:22px;font-weight:700;color:#10b981" id = "logCountHb" > -< / div >
< div style = "font-size:12px;color:#9ca3af" > 心跳记录< / div >
< / div >
< div class = "stat-card" style = "padding:14px;text-align:center;cursor:pointer" onclick = "document.getElementById('logTypeFilter').value='attendance';loadDataLog()" >
< div style = "font-size:22px;font-weight:700;color:#f59e0b" id = "logCountAtt" > -< / div >
< div style = "font-size:12px;color:#9ca3af" > 考勤记录< / div >
< / div >
< div class = "stat-card" style = "padding:14px;text-align:center;cursor:pointer" onclick = "document.getElementById('logTypeFilter').value='bluetooth';loadDataLog()" >
< div style = "font-size:22px;font-weight:700;color:#8b5cf6" id = "logCountBt" > -< / div >
< div style = "font-size:12px;color:#9ca3af" > 蓝牙记录< / div >
< / div >
< / div >
< div class = "bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" >
< div id = "datalogLoading" class = "loading-overlay" style = "display:none" > < div class = "spinner" > < / div > < / div >
< div class = "overflow-x-auto" >
< table >
< thead >
< tr >
2026-03-31 05:01:04 +00:00
< th style = "width:32px" > < input type = "checkbox" id = "logSelectAll" onchange = "toggleAllCheckboxes('log-sel-cb','logSelCount','btnBatchDeleteLog',this.checked)" title = "全选当前页" > < / th >
2026-03-27 13:04:11 +00:00
< th > ID< / th >
< th > 类型< / th >
< th > IMEI< / th >
< th > 详情< / th >
< th > 坐标< / th >
< th > 地址< / th >
< th > 时间< / th >
< / tr >
< / thead >
< tbody id = "datalogTableBody" >
2026-03-31 09:41:09 +00:00
< tr > < td colspan = "8" class = "text-center text-gray-500 py-8" > 选择筛选条件后点击查询< / td > < / tr >
2026-03-27 13:04:11 +00:00
< / tbody >
< / table >
< / div >
< div id = "datalogPagination" class = "pagination p-4" > < / div >
< / div >
< / div >
2026-03-27 10:19:34 +00:00
<!-- ==================== COMMANDS PAGE ==================== -->
< div id = "page-commands" class = "page" >
< 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 >
< span class = "guide-title" > 指令管理 — 远程控制设备与消息下发< / span >
< i class = "fas fa-chevron-down guide-toggle" > < / i >
< / div >
< 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 > : 向设备屏幕推送文字消息( UTF-16 编码,支持中文)< / div > < / div >
< div class = "guide-step" > < div class = "step-num" > 3< / div > < div class = "step-text" > < strong > 语音播报< / strong > :输入文字内容,设备通过 TTS 引擎语音朗读播报< / div > < / div >
< div class = "guide-step" > < div class = "step-num" > 4< / div > < div class = "step-text" > < strong > 历史查询< / strong > :按设备和状态筛选已发送的指令/消息/语音记录< / div > < / div >
< / div >
< div class = "guide-tips" > < p > < i class = "fas fa-exclamation-triangle" > < / i > 所有下发功能仅对当前在线( TCP 已连接)设备有效,离线设备无法接收< / p > < / div >
< / div >
< / div >
2026-03-31 05:01:04 +00:00
< div class = "stat-card mb-4" style = "display:flex;align-items:center;gap:16px;padding:12px 20px;" >
< label style = "white-space:nowrap;font-weight:600;" > < i class = "fas fa-broadcast-tower mr-2 text-cyan-400" > < / i > 目标设备< / label >
< select id = "cmdUnifiedDevice" style = "flex:1;max-width:360px;" >
< option value = "" > 选择设备...< / option >
< / select >
< span id = "cmdDeviceStatus" class = "text-xs text-gray-500" > < / span >
< / div >
2026-03-27 10:19:34 +00:00
< div class = "grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6" >
< div class = "stat-card" >
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-paper-plane mr-2 text-blue-400" > < / i > 发送指令< / h3 >
< div class = "form-group" >
< label > 指令类型< / label >
< input type = "text" id = "cmdType" placeholder = "如: LOCATE, POWEROFF, RESTART..." >
< / div >
< div class = "form-group" >
< label > 指令内容< / label >
< textarea id = "cmdContent" rows = "3" placeholder = "指令参数内容..." > < / textarea >
< / div >
< button class = "btn btn-primary w-full" onclick = "sendCommand()" > < i class = "fas fa-paper-plane" > < / i > 发送指令< / button >
< / div >
< div class = "stat-card" >
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-comment mr-2 text-green-400" > < / i > 发送消息< / h3 >
< div class = "form-group" >
< label > 消息内容< / label >
< textarea id = "msgContent" rows = "5" placeholder = "输入要发送给设备的消息..." > < / textarea >
< / div >
< button class = "btn btn-success w-full" onclick = "sendMessage()" > < i class = "fas fa-comment" > < / i > 发送消息< / button >
< / div >
< div class = "stat-card" >
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-volume-up mr-2 text-purple-400" > < / i > 语音播报< / h3 >
< div class = "form-group" >
< label > 播报文本< / label >
< textarea id = "ttsContent" rows = "3" placeholder = "输入语音播报内容,设备将通过 TTS 引擎朗读..." > < / textarea >
< / div >
< div class = "flex items-center gap-2 mb-3" >
< span class = "text-xs text-gray-500" > < i class = "fas fa-info-circle mr-1" > < / i > 最多 200 字,支持中英文混合< / span >
< span class = "text-xs text-gray-500 ml-auto" id = "ttsCharCount" > 0/200< / span >
< / div >
< button class = "btn w-full" style = "background:linear-gradient(135deg,#7c3aed,#6366f1);color:white;" onclick = "sendTTS()" > < i class = "fas fa-volume-up" > < / i > 语音下发< / button >
< / div >
< / div >
2026-03-31 10:11:33 +00:00
< div class = "grid grid-cols-2 md:grid-cols-4 gap-4 mb-4" id = "cmdStatsRow" >
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-terminal text-blue-400 mr-1" > < / i > 指令总数< / p >
< p class = "text-2xl font-bold" id = "cmdStatTotal" > -< / p >
< p class = "text-xs text-gray-500 mt-1" id = "cmdStatToday" > < / p >
< / div >
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-check-circle text-green-400 mr-1" > < / i > 成功率< / p >
< p class = "text-2xl font-bold text-green-400" id = "cmdStatRate" > -< / p >
< / div >
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-paper-plane text-cyan-400 mr-1" > < / i > 已发送< / p >
< p class = "text-2xl font-bold text-cyan-400" id = "cmdStatSent" > -< / p >
< / div >
< div class = "stat-card" style = "padding:16px;" >
< p class = "text-gray-400 text-xs mb-1" > < i class = "fas fa-times-circle text-red-400 mr-1" > < / i > 失败< / p >
< p class = "text-2xl font-bold text-red-400" id = "cmdStatFailed" > -< / p >
< / div >
< / div >
2026-03-27 10:19:34 +00:00
< div class = "flex flex-wrap items-center gap-3 mb-4" >
< h3 class = "text-lg font-semibold" > < i class = "fas fa-history mr-2 text-gray-400" > < / i > 指令历史< / h3 >
< div class = "flex-1" > < / div >
< select id = "cmdHistoryDeviceFilter" style = "width:180px" >
< option value = "" > 全部设备< / option >
< / select >
< select id = "cmdStatusFilter" style = "width:140px" >
< option value = "" > 全部状态< / option >
< option value = "pending" > 待发送< / option >
< option value = "sent" > 已发送< / option >
< option value = "success" > 成功< / option >
< option value = "failed" > 失败< / option >
< / select >
< button class = "btn btn-secondary" onclick = "loadCommands()" > < i class = "fas fa-sync-alt" > < / i > 刷新< / button >
< / div >
< div class = "bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" >
< div id = "commandsLoading" 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 = "commandsTableBody" >
< tr > < td colspan = "8" class = "text-center text-gray-500 py-8" > 加载中...< / td > < / tr >
< / tbody >
< / table >
< / div >
< div id = "commandsPagination" class = "pagination p-4" > < / div >
< / div >
< / div >
< / div >
< / main >
<!-- Modal Container -->
< div id = "modalContainer" > < / div >
< script >
// ==================== CORE UTILITIES ====================
const API_BASE = '/api';
let currentPage = 'dashboard';
let dashboardInterval = null;
2026-04-01 07:06:37 +00:00
let _ws = null; // WebSocket connection
let _wsReconnectTimer = null;
2026-03-27 10:19:34 +00:00
let locationMap = null;
let mapMarkers = [];
let mapPolyline = null;
2026-03-27 13:04:11 +00:00
let mapInfoWindows = []; // store {infoWindow, position} for each track point
2026-03-30 04:26:29 +00:00
let _locTableItems = []; // cached location records from table for on-the-fly marker creation
2026-03-27 13:04:11 +00:00
let trackPlayTimer = null;
let trackMovingMarker = null;
2026-03-27 10:19:34 +00:00
let dashAlarmChart = null;
let alarmTypeChart = null;
// Side panel state
let panelDevices = [];
let panelBeacons = [];
2026-03-27 13:04:11 +00:00
let panelFences = [];
2026-03-27 10:19:34 +00:00
let selectedPanelDeviceId = null;
let selectedPanelBeaconId = null;
2026-03-27 13:04:11 +00:00
let selectedPanelFenceId = null;
2026-03-27 10:19:34 +00:00
// Pagination state
const pageState = {
devices: { page: 1, pageSize: 20 },
locations: { page: 1, pageSize: 20 },
alarms: { page: 1, pageSize: 20 },
attendance: { page: 1, pageSize: 20 },
bluetooth: { page: 1, pageSize: 20 },
beacons: { page: 1, pageSize: 20 },
commands: { page: 1, pageSize: 20 },
};
function showToast(message, type = 'success') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icon = type === 'success' ? 'check-circle' : type === 'error' ? 'times-circle' : 'info-circle';
toast.innerHTML = `< i class = "fas fa-${icon}" > < / i > ${message}`;
container.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'fadeOut 0.3s ease forwards';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
async function apiCall(url, options = {}) {
try {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
const data = await response.json();
if (!response.ok) {
const detail = data.detail;
const msg = data.message || (typeof detail === 'string' ? detail : Array.isArray(detail) ? detail.map(e => e.msg || JSON.stringify(e)).join('; ') : null) || `HTTP ${response.status}`;
throw new Error(msg);
}
if (data.code !== undefined & & data.code !== 0) {
throw new Error(data.message || '请求失败');
}
return data.data !== undefined ? data.data : data;
} catch (err) {
console.error('API Error:', err);
throw err;
}
}
function showLoading(id) {
const el = document.getElementById(id);
if (el) el.style.display = 'flex';
}
function hideLoading(id) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
}
function formatTime(t) {
if (!t) return '-';
try {
const d = new Date(t);
if (isNaN(d.getTime())) return t;
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch { return t; }
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
function truncate(str, len = 40) {
if (!str) return '-';
str = String(str);
return str.length > len ? str.substring(0, len) + '...' : str;
}
function statusBadge(status) {
if (status === 'online') return '< span class = "badge badge-online" > 在线< / span > ';
return '< span class = "badge badge-offline" > 离线< / span > ';
}
function commandStatusBadge(status) {
const map = {
pending: '< span class = "badge badge-pending" > 待发送< / span > ',
sent: '< span class = "badge badge-sent" > 已发送< / span > ',
success: '< span class = "badge badge-success" > 成功< / span > ',
failed: '< span class = "badge badge-failed" > 失败< / span > ',
};
return map[status] || `< span class = "badge badge-pending" > ${escapeHtml(status)}< / span > `;
}
function alarmTypeName(type) {
const map = {
normal: '正常', sos: 'SOS', power_cut: '断电',
vibration: '振动', enter_fence: '进入围栏', exit_fence: '离开围栏',
over_speed: '超速', displacement: '位移',
enter_gps_dead_zone: '进入GPS盲区', exit_gps_dead_zone: '离开GPS盲区',
power_on: '开机', gps_first_fix: 'GPS首次定位',
low_battery: '低电量', low_battery_protection: '低电保护',
sim_change: 'SIM卡更换', power_off: '关机',
airplane_mode: '飞行模式', remove: '拆除',
2026-03-31 05:01:04 +00:00
door: '门', shutdown: '低电关机',
2026-03-27 10:19:34 +00:00
voice_alarm: '声控报警', fake_base_station: '伪基站',
cover_open: '开盖', internal_low_battery: '内部低电',
acc_on: 'ACC开', acc_off: 'ACC关',
};
return map[type] || type || '-';
}
function alarmTypeClass(type) {
const map = {
sos: 'alarm-sos', low_battery: 'alarm-low_battery',
remove: 'alarm-remove', enter_fence: 'alarm-fence',
exit_fence: 'alarm-fence', vibration: 'alarm-default',
power_cut: 'alarm-default', voice_alarm: 'alarm-default',
};
return map[type] || 'alarm-default';
}
function attendanceTypeName(type) {
const map = { clock_in: '签到', clock_out: '签退', check_in: '签到', check_out: '签退' };
return map[type] || type || '-';
}
function btTypeName(type) {
const map = { punch: '打卡', location: '定位' };
return map[type] || type || '-';
}
function animateCounter(elementId, targetValue) {
const el = document.getElementById(elementId);
if (!el) return;
const target = parseInt(targetValue) || 0;
const duration = 800;
const startTime = performance.now();
const startValue = parseInt(el.textContent) || 0;
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const current = Math.round(startValue + (target - startValue) * eased);
el.textContent = current;
if (progress < 1 ) requestAnimationFrame ( update ) ;
}
requestAnimationFrame(update);
}
function renderPagination(containerId, total, page, pageSize, loadFn) {
const container = document.getElementById(containerId);
if (!container) return;
const totalPages = Math.ceil(total / pageSize) || 1;
if (totalPages < = 1) { container.innerHTML = ''; return; }
let html = `< button $ { page < = 1 ? ' disabled ' : ' ' } onclick = "${loadFn}(${page - 1})" > < i class = "fas fa-chevron-left" > < / i > < / button > `;
const maxVisible = 5;
let start = Math.max(1, page - Math.floor(maxVisible / 2));
let end = Math.min(totalPages, start + maxVisible - 1);
if (end - start < maxVisible - 1 ) start = Math.max(1, end - maxVisible + 1 ) ;
if (start > 1) { html += `< button onclick = "${loadFn}(1)" > 1< / button > `; if (start > 2) html += `< span class = "text-gray-500 px-1" > ...< / span > `; }
for (let i = start; i < = end; i++) {
html += `< button class = "${i === page ? 'active' : ''}" onclick = "${loadFn}(${i})" > ${i}< / button > `;
}
if (end < totalPages ) { if ( end < totalPages - 1 ) html + = ` < span class = "text-gray-500 px-1" > ...< / span > `; html += `< button onclick = "${loadFn}(${totalPages})" > ${totalPages}< / button > `; }
html += `< button $ { page > = totalPages ? 'disabled' : ''} onclick="${loadFn}(${page + 1})">< i class = "fas fa-chevron-right" > < / i > < / button > `;
html += `< span class = "text-gray-500 text-sm ml-2" > 共 ${total} 条< / span > `;
container.innerHTML = html;
}
function showModal(html) {
const container = document.getElementById('modalContainer');
container.innerHTML = `< div class = "modal-backdrop" onclick = "if(event.target===this)closeModal()" > < div class = "modal-content" > ${html}< / div > < / div > `;
}
function closeModal() {
if (typeof destroyBeaconPickerMap === 'function') destroyBeaconPickerMap();
2026-03-27 13:04:11 +00:00
if (typeof destroyFenceDrawMap === 'function') destroyFenceDrawMap();
2026-03-27 10:19:34 +00:00
document.getElementById('modalContainer').innerHTML = '';
}
function updateClock() {
const now = new Date();
document.getElementById('currentTime').textContent = now.toLocaleString('zh-CN');
}
setInterval(updateClock, 1000);
updateClock();
// ==================== NAVIGATION ====================
const pageTitles = {
dashboard: '仪表盘', devices: '设备管理', locations: '位置追踪',
2026-03-27 13:04:11 +00:00
alarms: '告警管理', attendance: '考勤记录', bluetooth: '蓝牙记录', beacons: '信标管理', fences: '围栏管理', datalog: '数据日志', commands: '指令管理'
2026-03-27 10:19:34 +00:00
};
function navigateTo(page) {
currentPage = page;
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
document.querySelector(`.nav-item[data-page="${page}"]`).classList.add('active');
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 = []; }
2026-03-27 13:04:11 +00:00
if (page !== 'fences') { selectedPanelFenceId = null; panelFences = []; }
2026-03-27 10:19:34 +00:00
switch (page) {
case 'dashboard': loadDashboard(); dashboardInterval = setInterval(loadDashboard, 30000); break;
case 'devices': loadDevices(); break;
2026-03-30 09:41:55 +00:00
case 'locations': initLocationMap(); loadDeviceSelectors(); break;
2026-03-27 10:19:34 +00:00
case 'alarms': loadAlarmStats(); loadAlarms(); loadDeviceSelectors(); break;
case 'attendance': loadAttendanceStats(); loadAttendance(); loadDeviceSelectors(); break;
2026-03-31 10:11:33 +00:00
case 'bluetooth': loadBluetoothStats(); loadBluetooth(); loadDeviceSelectors(); break;
2026-03-27 10:19:34 +00:00
case 'beacons': loadBeacons(); break;
2026-03-31 10:11:33 +00:00
case 'fences': initFenceMap(); loadFenceStats(); loadFences(); break;
2026-03-27 13:04:11 +00:00
case 'datalog': loadDataLogStats(); loadDataLog(); loadDeviceSelectors(); break;
2026-03-31 10:11:33 +00:00
case 'commands': loadCommandStats(); loadCommands(); loadDeviceSelectors(); break;
2026-03-27 10:19:34 +00:00
}
}
// ==================== 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' },
2026-03-27 13:04:11 +00:00
fences: { panel: 'fenceSidePanel', list: 'fencePanelList', count: 'fencePanelCount', footer: 'fencePanelFooter', search: 'fencePanelSearch' },
2026-03-27 10:19:34 +00:00
};
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');
2026-03-27 13:04:11 +00:00
// Resize map when panel toggled
2026-03-27 10:19:34 +00:00
if (currentPage === 'locations' & & locationMap) {
2026-03-27 13:04:11 +00:00
setTimeout(() => { if (locationMap) locationMap.resize(); }, PANEL_TRANSITION_MS + 50);
}
if (currentPage === 'fences' & & fenceMap) {
setTimeout(() => { if (fenceMap) fenceMap.resize(); }, PANEL_TRANSITION_MS + 50);
2026-03-27 10:19:34 +00:00
}
}
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' });
2026-03-30 09:41:55 +00:00
// Reload location records filtered by selected device
loadLocationRecords(1);
}
function onLocDeviceSelectChange(deviceId) {
selectedPanelDeviceId = deviceId;
document.querySelectorAll('#locPanelList .panel-item').forEach(el => {
el.classList.toggle('active', el.dataset.deviceId == deviceId);
});
loadLocationRecords(1);
2026-03-27 10:19:34 +00:00
}
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;
2026-03-31 09:41:09 +00:00
let _devIdToImei = {}; // {device_id: imei} global mapping
function _imei(deviceId) {
return _devIdToImei[deviceId] || deviceId || '-';
}
2026-03-27 10:19:34 +00:00
2026-04-01 07:06:37 +00:00
async function exportCSV(type) {
const params = new URLSearchParams();
if (type === 'devices') {
const search = document.getElementById('deviceSearch')?.value;
const status = document.getElementById('deviceStatusFilter')?.value;
if (search) params.set('search', search);
if (status) params.set('status', status);
} else if (type === 'locations') {
const did = document.getElementById('locDeviceSelect')?.value;
const lt = document.getElementById('locTypeFilter')?.value;
const sd = document.getElementById('locStartDate')?.value;
const ed = document.getElementById('locEndDate')?.value;
if (did) params.set('device_id', did);
if (lt) params.set('location_type', lt);
if (sd) params.set('start_time', sd + 'T00:00:00');
if (ed) params.set('end_time', ed + 'T23:59:59');
} else if (type === 'alarms') {
const did = document.getElementById('alarmDeviceFilter')?.value;
const at = document.getElementById('alarmTypeFilter')?.value;
const ack = document.getElementById('alarmAckFilter')?.value;
const sd = document.getElementById('alarmStartDate')?.value;
const ed = document.getElementById('alarmEndDate')?.value;
if (did) params.set('device_id', did);
if (at) params.set('alarm_type', at);
if (ack) params.set('acknowledged', ack);
if (sd) params.set('start_time', sd + 'T00:00:00');
if (ed) params.set('end_time', ed + 'T23:59:59');
} else if (type === 'attendance') {
const did = document.getElementById('attDeviceFilter')?.value;
const at = document.getElementById('attTypeFilter')?.value;
const src = document.getElementById('attSourceFilter')?.value;
const sd = document.getElementById('attStartDate')?.value;
const ed = document.getElementById('attEndDate')?.value;
if (did) params.set('device_id', did);
if (at) params.set('attendance_type', at);
if (src) params.set('attendance_source', src);
if (sd) params.set('start_time', sd + 'T00:00:00');
if (ed) params.set('end_time', ed + 'T23:59:59');
} else if (type === 'bluetooth') {
const did = document.getElementById('btDeviceFilter')?.value;
const rt = document.getElementById('btTypeFilter')?.value;
const sd = document.getElementById('btStartDate')?.value;
const ed = document.getElementById('btEndDate')?.value;
if (did) params.set('device_id', did);
if (rt) params.set('record_type', rt);
if (sd) params.set('start_time', sd + 'T00:00:00');
if (ed) params.set('end_time', ed + 'T23:59:59');
}
const qs = params.toString();
const url = `${API_BASE}/${type}/export${qs ? '?' + qs : ''}`;
try {
showToast('正在导出...');
const resp = await fetch(url);
if (!resp.ok) throw new Error(`导出失败: HTTP ${resp.status}`);
const blob = await resp.blob();
const cd = resp.headers.get('Content-Disposition') || '';
const match = cd.match(/filename=(.+)/);
const filename = match ? match[1] : `${type}_export.csv`;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
showToast('导出完成');
} catch (err) {
showToast('导出失败: ' + err.message, 'error');
}
}
2026-03-27 10:19:34 +00:00
async function loadDeviceSelectors() {
try {
const data = await apiCall(`${API_BASE}/devices?page=1&page_size=100`);
const devices = data.items || [];
cachedDevices = devices;
2026-03-31 09:41:09 +00:00
// Build global device_id -> imei mapping
_devIdToImei = {};
devices.forEach(d => { _devIdToImei[d.id || d.device_id] = d.imei; });
2026-03-31 05:01:04 +00:00
const selectors = ['locDeviceSelect', 'alarmDeviceFilter', 'attDeviceFilter', 'btDeviceFilter', 'cmdHistoryDeviceFilter'];
2026-03-27 10:19:34 +00:00
selectors.forEach(id => {
const sel = document.getElementById(id);
if (!sel) return;
const currentVal = sel.value;
const firstOption = sel.options[0].outerHTML;
sel.innerHTML = firstOption;
devices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.id || d.device_id || '';
opt.textContent = `${d.name || d.imei || d.id} (${d.imei || ''})`;
sel.appendChild(opt);
});
if (currentVal) sel.value = currentVal;
});
2026-03-31 05:01:04 +00:00
// Unified command device selector with online/offline status
const cmdSel = document.getElementById('cmdUnifiedDevice');
if (cmdSel) {
const curVal = cmdSel.value;
cmdSel.innerHTML = '< option value = "" > 选择设备...< / option > ';
const online = devices.filter(d => d.status === 'online');
const offline = devices.filter(d => d.status !== 'online');
if (online.length) {
const grpOn = document.createElement('optgroup');
grpOn.label = `在线 (${online.length})`;
online.forEach(d => {
const o = document.createElement('option');
o.value = d.id || d.device_id || '';
o.textContent = `🟢 ${d.name || d.imei} (${d.imei || ''})`;
grpOn.appendChild(o);
});
cmdSel.appendChild(grpOn);
}
if (offline.length) {
const grpOff = document.createElement('optgroup');
grpOff.label = `离线 (${offline.length})`;
offline.forEach(d => {
const o = document.createElement('option');
o.value = d.id || d.device_id || '';
o.textContent = `⚪ ${d.name || d.imei} (${d.imei || ''})`;
o.style.color = '#6b7280';
grpOff.appendChild(o);
});
cmdSel.appendChild(grpOff);
}
if (curVal) cmdSel.value = curVal;
// Update status hint on change
cmdSel.onchange = () => {
const st = document.getElementById('cmdDeviceStatus');
if (!st) return;
const did = cmdSel.value;
if (!did) { st.textContent = ''; return; }
const dev = devices.find(d => String(d.id) === did || String(d.device_id) === did);
if (dev & & dev.status === 'online') {
st.innerHTML = '< span style = "color:#22c55e" > < i class = "fas fa-circle" style = "font-size:8px;vertical-align:middle;margin-right:4px" > < / i > 在线 - 可下发< / span > ';
} else {
st.innerHTML = '< span style = "color:#ef4444" > < i class = "fas fa-circle" style = "font-size:8px;vertical-align:middle;margin-right:4px" > < / i > 离线 - 无法接收指令< / span > ';
}
};
}
2026-03-27 10:19:34 +00:00
// Render device panel on locations page
if (currentPage === 'locations' & & document.getElementById('locPanelList')) {
const sorted = sortDevicesByActivity(devices);
renderDevicePanel(sorted);
2026-03-30 09:41:55 +00:00
if (!selectedPanelDeviceId) {
autoSelectActiveDevice(sorted); // this calls selectPanelDevice → loadLocationRecords
}
// If no devices or already selected, ensure records are loaded
if (!sorted.length || selectedPanelDeviceId) loadLocationRecords(1);
2026-03-27 10:19:34 +00:00
}
} catch (err) {
console.error('Failed to load device selectors:', err);
}
}
// ==================== DASHBOARD ====================
async function loadDashboard() {
try {
2026-03-31 10:11:33 +00:00
const [deviceStats, alarmStats, health, overview] = await Promise.allSettled([
2026-03-27 10:19:34 +00:00
apiCall(`${API_BASE}/devices/stats`),
apiCall(`${API_BASE}/alarms/stats`),
apiCall('/health'),
2026-03-31 10:11:33 +00:00
apiCall(`${API_BASE}/system/overview`),
2026-03-27 10:19:34 +00:00
]);
if (deviceStats.status === 'fulfilled') {
const ds = deviceStats.value;
animateCounter('dashTotalDevices', ds.total || 0);
animateCounter('dashOnlineDevices', ds.online || 0);
animateCounter('dashOfflineDevices', ds.offline || 0);
}
if (alarmStats.status === 'fulfilled') {
const as = alarmStats.value;
animateCounter('dashTotalAlarms', as.total || 0);
animateCounter('dashUnackAlarms', as.unacknowledged || 0);
renderAlarmDoughnut('dashAlarmChart', as.by_type, false);
}
if (health.status === 'fulfilled') {
const h = health.value;
const status = h.status || 'unknown';
const connected = h.connected_devices || 0;
document.getElementById('dashSystemStatus').textContent = status === 'ok' || status === 'healthy' ? '正常运行' : status;
2026-03-31 05:01:04 +00:00
const hint = document.getElementById('dashConnectedHint');
if (hint) hint.textContent = `TCP ${connected} 台`;
2026-03-27 10:19:34 +00:00
document.getElementById('healthStatus').innerHTML = `< i class = "fas fa-circle text-green-500 mr-1 text-xs" > < / i > 在线 (${connected}台)`;
} else {
document.getElementById('healthStatus').innerHTML = `< i class = "fas fa-circle text-red-500 mr-1 text-xs" > < / i > 离线`;
document.getElementById('dashSystemStatus').textContent = '无法连接';
}
2026-03-31 10:11:33 +00:00
if (overview.status === 'fulfilled') {
const ov = overview.value;
animateCounter('dashTodayAlarms', ov.today?.alarms || 0);
animateCounter('dashTodayAttendance', ov.today?.attendance || 0);
animateCounter('dashTodayLocations', ov.today?.locations || 0);
const rate = ov.devices?.online_rate;
document.getElementById('dashOnlineRate').textContent = rate != null ? rate + '%' : '-';
}
2026-03-31 05:01:04 +00:00
// Update last-refreshed timestamp
const upd = document.getElementById('dashLastUpdated');
if (upd) upd.textContent = '更新于 ' + new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
2026-03-27 10:19:34 +00:00
// Load recent alarms
try {
const alarms = await apiCall(`${API_BASE}/alarms?page=1&page_size=10`);
const items = alarms.items || [];
const container = document.getElementById('dashRecentAlarms');
if (items.length === 0) {
container.innerHTML = '< p class = "text-gray-500 text-sm text-center py-4" > 暂无告警< / p > ';
} else {
container.innerHTML = items.map(a => `
< div class = "flex items-center justify-between p-2 rounded-lg hover:bg-gray-700/50 transition-colors" >
< div class = "flex items-center gap-3" >
< 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 >
2026-03-31 09:41:09 +00:00
< span class = "text-xs text-gray-500 ml-2" > IMEI: ${escapeHtml(_imei(a.device_id))}< / span >
2026-03-27 10:19:34 +00:00
< / div >
< / div >
< div class = "flex items-center gap-2" >
${a.acknowledged ? '< span class = "badge badge-online" > 已确认< / span > ' : '< span class = "badge badge-offline" > 未确认< / span > '}
< span class = "text-xs text-gray-500" > ${formatTime(a.recorded_at)}< / span >
< / div >
< / div >
`).join('');
}
} catch (err) {
document.getElementById('dashRecentAlarms').innerHTML = '< p class = "text-gray-500 text-sm" > 加载失败< / p > ';
}
} catch (err) {
console.error('Dashboard load error:', err);
}
}
function renderAlarmDoughnut(canvasId, byType, isSmall) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const labels = [];
const values = [];
const colors = [];
2026-03-31 05:01:04 +00:00
const colorMap = {
sos: '#ef4444', power_cut: '#dc2626', vibration: '#f97316', low_battery: '#fb923c',
low_battery_protection: '#fdba74', internal_low_battery: '#fbbf24',
enter_fence: '#a855f7', exit_fence: '#c084fc', displacement: '#8b5cf6',
over_speed: '#f43f5e', remove: '#eab308', cover_open: '#facc15',
power_on: '#22c55e', power_off: '#64748b', shutdown: '#475569',
sim_change: '#06b6d4', airplane_mode: '#0ea5e9',
enter_gps_dead_zone: '#94a3b8', exit_gps_dead_zone: '#cbd5e1',
gps_first_fix: '#34d399', door: '#14b8a6',
voice_alarm: '#e879f9', fake_base_station: '#f472b6',
normal: '#6b7280', acc_on: '#10b981', acc_off: '#9ca3af',
};
const nameMap = {
normal: '正常', sos: 'SOS', power_cut: '断电', vibration: '振动',
enter_fence: '进围栏', exit_fence: '出围栏', over_speed: '超速',
displacement: '位移', enter_gps_dead_zone: '进GPS盲区', exit_gps_dead_zone: '出GPS盲区',
power_on: '开机', gps_first_fix: 'GPS首次定位', low_battery: '低电量',
low_battery_protection: '低电保护', sim_change: 'SIM更换', power_off: '关机',
airplane_mode: '飞行模式', remove: '拆除', door: '门', shutdown: '低电关机',
voice_alarm: '声控', fake_base_station: '伪基站', cover_open: '开盖',
internal_low_battery: '内部低电', acc_on: 'ACC开', acc_off: 'ACC关',
};
2026-03-27 10:19:34 +00:00
if (byType & & typeof byType === 'object') {
Object.entries(byType).forEach(([key, val]) => {
labels.push(nameMap[key] || key);
values.push(val);
colors.push(colorMap[key] || '#6b7280');
});
}
if (labels.length === 0) {
labels.push('无数据');
values.push(1);
colors.push('#374151');
}
const existingChart = canvasId === 'dashAlarmChart' ? dashAlarmChart : alarmTypeChart;
if (existingChart) existingChart.destroy();
const chart = new Chart(canvas, {
type: 'doughnut',
data: { labels, datasets: [{ data: values, backgroundColor: colors, borderWidth: 0 }] },
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
plugins: {
legend: { position: isSmall ? 'bottom' : 'right', labels: { color: '#9ca3af', padding: 12, font: { size: isSmall ? 10 : 12 } } }
}
}
});
if (canvasId === 'dashAlarmChart') dashAlarmChart = chart;
else alarmTypeChart = chart;
}
// ==================== DEVICES ====================
2026-03-31 09:41:09 +00:00
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 >
2026-04-01 09:34:42 +00:00
< button class = "dev-qcmd" $ { dis } onclick = "_devSetupBtMode(${did})" style = "color:#a855f7" > < i class = "fas fa-bluetooth-b" > < / i > 蓝牙< / button >
< button class = "dev-qcmd" $ { dis } onclick = "_devRestoreNormal(${did})" style = "color:#22c55e" > < i class = "fas fa-undo" > < / i > 正常< / button >
2026-03-31 09:41:09 +00:00
< 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();
});
2026-03-27 10:19:34 +00:00
async function loadDevices(page) {
if (page) pageState.devices.page = page;
const p = pageState.devices.page;
const ps = pageState.devices.pageSize;
const search = document.getElementById('deviceSearch').value.trim();
const status = document.getElementById('deviceStatusFilter').value;
let url = `${API_BASE}/devices?page=${p}&page_size=${ps}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
if (status) url += `&status=${status}`;
showLoading('devicesLoading');
try {
const data = await apiCall(url);
2026-03-31 09:41:09 +00:00
_devItems = data.items || [];
_devLocModes = {};
_renderDeviceRows();
_updateSortArrows();
2026-03-27 10:19:34 +00:00
renderPagination('devicesPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadDevices');
2026-03-31 09:41:09 +00:00
// Fetch latest location types asynchronously
if (_devItems.length) {
_fillDeviceLocModes(_devItems.map(d => d.id));
}
2026-03-27 10:19:34 +00:00
} catch (err) {
showToast('加载设备列表失败: ' + err.message, 'error');
2026-03-31 09:41:09 +00:00
document.getElementById('devicesTableBody').innerHTML = '< tr > < td colspan = "10" class = "text-center text-red-400 py-8" > 加载失败< / td > < / tr > ';
2026-03-27 10:19:34 +00:00
} finally {
hideLoading('devicesLoading');
}
}
function showAddDeviceModal() {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-plus-circle mr-2 text-blue-400" > < / i > 添加设备< / h3 >
< div class = "form-group" > < label > IMEI < span class = "text-red-400" > *< / span > < / label > < input type = "text" id = "addDeviceImei" placeholder = "15-20位IMEI号" maxlength = "20" > < p class = "text-xs text-gray-500 mt-1" > 设备背面标签上的IMEI号, 15-20位数字< / p > < / div >
< div class = "form-group" > < label > 设备类型 < span class = "text-red-400" > *< / span > < / label > < select id = "addDeviceType" > < option value = "P240" > P240< / option > < option value = "P241" > P241< / option > < option value = "badge" > badge< / option > < option value = "watch" > watch< / option > < / select > < p class = "text-xs text-gray-500 mt-1" > 选择设备型号< / p > < / div >
< div class = "form-group" > < label > 名称< / label > < input type = "text" id = "addDeviceName" placeholder = "如: 张三的工牌" > < p class = "text-xs text-gray-500 mt-1" > 可选,方便识别的设备名称< / p > < / div >
< div class = "form-group" > < label > 时区< / label > < input type = "text" id = "addDeviceTimezone" placeholder = "+8 或 Asia/Shanghai" value = "+8" > < p class = "text-xs text-gray-500 mt-1" > 默认 +8( 东八区) < / p > < / div >
< div class = "form-group" > < label > 语言< / label > < select id = "addDeviceLang" > < option value = "cn" > cn - 中文< / option > < option value = "en" > en - English< / option > < / select > < / div >
< div class = "flex gap-3 mt-6" >
< button class = "btn btn-primary flex-1" onclick = "submitAddDevice()" > < i class = "fas fa-check" > < / i > 确认添加< / button >
< button class = "btn btn-secondary flex-1" onclick = "closeModal()" > < i class = "fas fa-times" > < / i > 取消< / button >
< / div >
`);
}
async function submitAddDevice() {
const imei = document.getElementById('addDeviceImei').value.trim();
const deviceType = document.getElementById('addDeviceType').value.trim();
if (!imei) { showToast('请输入IMEI号', 'error'); return; }
if (imei.length < 15 | | imei . length > 20) { showToast('IMEI号长度应为15-20位', 'error'); return; }
if (!deviceType) { showToast('请选择设备类型', 'error'); return; }
const body = { imei, device_type: deviceType };
const name = document.getElementById('addDeviceName').value.trim();
const timezone = document.getElementById('addDeviceTimezone').value.trim();
const language = document.getElementById('addDeviceLang').value.trim();
if (name) body.name = name;
if (timezone) body.timezone = timezone;
if (language) body.language = language;
try {
await apiCall(`${API_BASE}/devices`, { method: 'POST', body: JSON.stringify(body) });
showToast('设备添加成功');
closeModal();
loadDevices();
} catch (err) {
showToast('添加失败: ' + err.message, 'error');
}
}
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 || '-';
}
2026-03-31 09:41:09 +00:00
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 */ }
}
2026-03-27 10:19:34 +00:00
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('');
}
2026-03-27 13:04:11 +00:00
// --- Map info window builder (black text for readability) ---
function _buildInfoContent(title, loc, lat, lng) {
const q = _locQuality(loc);
return `< div style = "color:#333;font-size:13px;line-height:1.6;min-width:200px" >
< b style = "font-size:14px;color:#111" > ${title}< / b >
< span style = "display:inline-block;padding:1px 6px;border-radius:4px;font-size:11px;background:${q.bg};color:${q.fg};margin-left:6px" > ${q.label}< / span > < br >
< span style = "color:#666" > 类型:< / span > ${_locTypeLabel(loc.location_type)}< br >
< span style = "color:#666" > 坐标:< / span > ${lat.toFixed(6)}, ${lng.toFixed(6)}< br >
${loc.address ? `< span style = "color:#666" > 地址:< / span > ${escapeHtml(loc.address)}< br > ` : ''}
${loc.speed != null ? `< span style = "color:#666" > 速度:< / span > ${loc.speed} km/h< br > ` : ''}
< span style = "color:#666" > 时间:< / span > ${formatTime(loc.recorded_at || loc.created_at)}
< / div > `;
}
// --- Track playback animation ---
function playTrack() {
if (mapInfoWindows.length < 2 ) { showToast ( ' 请先加载轨迹 ( 至少2个点 ) ' , ' error ' ) ; return ; }
// Stop existing playback
if (trackPlayTimer) { cancelAnimationFrame(trackPlayTimer); trackPlayTimer = null; }
if (trackMovingMarker) { locationMap.remove(trackMovingMarker); trackMovingMarker = null; }
const positions = mapInfoWindows.map(iw => iw.position);
trackMovingMarker = new AMap.Marker({
position: positions[0],
icon: new AMap.Icon({ size: new AMap.Size(24, 34), image: 'https://webapi.amap.com/theme/v1.3/markers/n/mark_r.png', imageSize: new AMap.Size(24, 34) }),
offset: new AMap.Pixel(-12, -34),
zIndex: 200,
});
trackMovingMarker.setMap(locationMap);
showToast('路径回放中...', 'info');
2026-03-30 04:26:29 +00:00
locationMap.setCenter(positions[0]);
locationMap.setZoom(16);
2026-03-27 13:04:11 +00:00
mapInfoWindows[0].infoWindow.open(locationMap, positions[0]);
let segIdx = 0; // current segment (from segIdx to segIdx+1)
let segProgress = 0; // 0~1 progress within segment
const DURATION_PER_SEG = 1200; // ms per segment
const STEPS_PER_SEG = 60;
let lastTime = null;
function animate(timestamp) {
if (!lastTime) lastTime = timestamp;
const dt = timestamp - lastTime;
lastTime = timestamp;
segProgress += dt / DURATION_PER_SEG;
if (segProgress >= 1) {
segProgress = 0;
segIdx++;
if (segIdx >= positions.length - 1) {
// Arrived at last point
trackMovingMarker.setPosition(positions[positions.length - 1]);
locationMap.setCenter(positions[positions.length - 1]);
mapInfoWindows.forEach(iw => iw.infoWindow.close());
mapInfoWindows[positions.length - 1].infoWindow.open(locationMap, positions[positions.length - 1]);
showToast('回放结束');
trackPlayTimer = null;
return;
}
// Show info at each waypoint
mapInfoWindows.forEach(iw => iw.infoWindow.close());
mapInfoWindows[segIdx].infoWindow.open(locationMap, positions[segIdx]);
}
// Interpolate position between segIdx and segIdx+1
const from = positions[segIdx];
const to = positions[segIdx + 1];
const curLng = from[0] + (to[0] - from[0]) * segProgress;
const curLat = from[1] + (to[1] - from[1]) * segProgress;
trackMovingMarker.setPosition([curLng, curLat]);
locationMap.setCenter([curLng, curLat]);
trackPlayTimer = requestAnimationFrame(animate);
}
trackPlayTimer = requestAnimationFrame(animate);
}
// --- Focus a location on map by record id (called from table row) ---
2026-03-30 04:26:29 +00:00
let _focusInfoWindow = null; // single info window for table-click focus
let _focusMarker = null; // single marker for table-click focus
2026-03-27 13:04:11 +00:00
function focusMapPoint(locId) {
2026-03-30 04:26:29 +00:00
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]);
2026-03-31 09:41:09 +00:00
_focusMarker.on('mouseover', () => _focusInfoWindow.open(locationMap, [mLng, mLat]));
_focusMarker.on('mouseout', () => _focusInfoWindow.close());
2026-03-30 04:26:29 +00:00
_focusMarker.on('click', () => _focusInfoWindow.open(locationMap, [mLng, mLat]));
locationMap.setCenter([mLng, mLat]);
locationMap.setZoom(17);
2026-03-27 13:04:11 +00:00
document.getElementById('locationMap').scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// --- Location quality indicator ---
function _locQuality(l) {
const type = l.location_type || '';
const hasCoord = l.latitude != null & & l.longitude != null;
if (!hasCoord) return { label: '无坐标', bg: '#7f1d1d', fg: '#fca5a5' };
// GPS with satellites
if (type.startsWith('gps')) {
const sat = l.gps_satellites || 0;
if (sat >= 6) return { label: `GPS ${sat}星`, bg: '#065f46', fg: '#6ee7b7' };
if (sat >= 3) return { label: `GPS ${sat}星`, bg: '#78350f', fg: '#fde68a' };
return { label: `GPS ${sat}星`, bg: '#7f1d1d', fg: '#fca5a5' };
}
// LBS — low accuracy (~500m-2km)
if (type.startsWith('lbs')) return { label: 'LBS 低精度', bg: '#78350f', fg: '#fde68a' };
// WiFi — medium accuracy (~30-100m)
if (type.startsWith('wifi')) return { label: 'WiFi 中精度', bg: '#1e3a5f', fg: '#93c5fd' };
// Bluetooth — depends on beacon config
if (type === 'bluetooth') return { label: '蓝牙', bg: '#3b1f5f', fg: '#c4b5fd' };
return { label: type || '未知', bg: '#374151', fg: '#9ca3af' };
}
async function deleteLocationRecord(id) {
if (!confirm('确定删除这条位置记录?')) return;
try {
await apiCall(`${API_BASE}/locations/${id}`, { method: 'DELETE' });
showToast('已删除');
loadLocationRecords();
} catch (err) {
showToast('删除失败: ' + err.message, 'error');
}
}
2026-03-31 02:03:21 +00:00
// ==================== GENERIC BATCH DELETE HELPERS ====================
function toggleAllCheckboxes(cbClass, countSpanId, btnId, checked) {
document.querySelectorAll('.' + cbClass).forEach(cb => { cb.checked = checked; });
updateSelCount(cbClass, countSpanId, btnId);
2026-03-30 04:26:29 +00:00
}
2026-03-31 02:03:21 +00:00
function updateSelCount(cbClass, countSpanId, btnId) {
const count = document.querySelectorAll('.' + cbClass + ':checked').length;
document.getElementById(countSpanId).textContent = count;
document.getElementById(btnId).disabled = count === 0;
2026-03-30 04:26:29 +00:00
}
2026-03-31 02:03:21 +00:00
// Location (compat wrappers)
function toggleAllLocCheckboxes(checked) { toggleAllCheckboxes('loc-sel-cb','locSelCount','btnBatchDeleteLoc', checked); }
function updateLocSelCount() { updateSelCount('loc-sel-cb','locSelCount','btnBatchDeleteLoc'); }
2026-03-30 04:26:29 +00:00
2026-03-31 02:03:21 +00:00
async function _batchDelete(cbClass, apiPath, idKey, label, reloadFn) {
const ids = Array.from(document.querySelectorAll('.' + cbClass + ':checked')).map(cb => parseInt(cb.value));
2026-03-30 04:26:29 +00:00
if (ids.length === 0) { showToast('请先勾选要删除的记录', 'info'); return; }
2026-03-31 02:03:21 +00:00
if (!confirm(`确定批量删除选中的 ${ids.length} 条${label}? `)) return;
2026-03-30 04:26:29 +00:00
try {
2026-03-31 02:03:21 +00:00
const result = await apiCall(`${API_BASE}/${apiPath}`, {
method: 'POST', body: JSON.stringify({ [idKey]: ids }),
2026-03-30 04:26:29 +00:00
});
showToast(`已删除 ${result.deleted} 条记录`);
2026-03-31 02:03:21 +00:00
reloadFn();
2026-03-30 04:26:29 +00:00
} catch (err) {
showToast('批量删除失败: ' + err.message, 'error');
}
}
2026-03-31 02:03:21 +00:00
function batchDeleteSelectedLocations() { _batchDelete('loc-sel-cb', 'locations/batch-delete', 'location_ids', '位置记录', loadLocationRecords); }
function batchDeleteSelectedAlarms() { _batchDelete('alarm-sel-cb', 'alarms/batch-delete', 'alarm_ids', '告警记录', loadAlarms); }
function batchDeleteSelectedAttendance() { _batchDelete('att-sel-cb', 'attendance/batch-delete', 'attendance_ids', '考勤记录', loadAttendance); }
function batchDeleteSelectedBluetooth() { _batchDelete('bt-sel-cb', 'bluetooth/batch-delete', 'record_ids', '蓝牙记录', loadBluetooth); }
function batchDeleteSelectedDatalog() {
const logType = document.getElementById('logTypeFilter').value || 'location';
const apiMap = { location: ['locations/batch-delete','location_ids'], alarm: ['alarms/batch-delete','alarm_ids'], attendance: ['attendance/batch-delete','attendance_ids'], bluetooth: ['bluetooth/batch-delete','record_ids'], heartbeat: ['heartbeats/batch-delete','record_ids'] };
const [path, key] = apiMap[logType] || apiMap.location;
_batchDelete('log-sel-cb', path, key, '记录', loadDataLog);
}
2026-03-30 04:26:29 +00:00
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');
}
}
2026-03-27 10:19:34 +00:00
// --- Quick command sender for device detail panel ---
2026-03-31 09:41:09 +00:00
async function _devQuickCmd(deviceId, cmd, btnEl) {
2026-04-01 07:06:37 +00:00
// Find device info
const dev = cachedDevices.find(d => d.id == deviceId);
const devLabel = dev ? (dev.name || dev.imei) : `设备${deviceId}`;
// Show modal immediately
const modalId = '_qcmd_modal_' + Date.now();
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-terminal mr-2 text-cyan-400" > < / i > 指令发送 — ${escapeHtml(devLabel)}< / h3 >
< div id = "${modalId}" style = "font-size:13px" >
< div style = "display:flex;align-items:center;gap:8px;margin-bottom:12px" >
< span style = "color:#9ca3af" > 指令:< / span >
< code style = "background:#1e293b;padding:4px 10px;border-radius:6px;color:#60a5fa;font-size:14px" > ${escapeHtml(cmd)}< / code >
< / div >
< div id = "${modalId}_status" style = "background:#1e293b;border-radius:8px;padding:12px;min-height:60px" >
< div style = "display:flex;align-items:center;gap:6px;color:#f59e0b" >
< i class = "fas fa-spinner fa-spin" > < / i > < span > 发送中...< / span >
< / div >
< / div >
< / div >
< div class = "flex justify-end mt-4" > < button class = "btn btn-secondary" onclick = "closeModal()" > 关闭< / button > < / div >
`);
2026-03-31 09:41:09 +00:00
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 }),
});
2026-04-01 07:06:37 +00:00
const statusEl = document.getElementById(`${modalId}_status`);
if (!statusEl) return;
// Update modal: sent successfully, waiting for response
statusEl.innerHTML = `
< div style = "margin-bottom:8px" > < span style = "color:#22c55e" > < i class = "fas fa-check-circle" > < / i > 已发送< / span > < / div >
< div style = "display:flex;align-items:center;gap:6px;color:#f59e0b" id = "${modalId}_wait" >
< i class = "fas fa-spinner fa-spin" > < / i > < span > 等待设备回复...< / span >
< / div >
< div id = "${modalId}_resp" style = "display:none;margin-top:8px" > < / div >
`;
// Poll for response in background (don't block)
2026-03-31 09:41:09 +00:00
const cmdId = res & & res.id;
if (cmdId) {
2026-04-01 07:06:37 +00:00
(async () => {
for (let i = 0; i < 8 ; i + + ) {
await new Promise(r => setTimeout(r, 1500));
try {
const c = await apiCall(`${API_BASE}/commands/${cmdId}`);
if (c.response_content) {
const waitEl = document.getElementById(`${modalId}_wait`);
const respEl = document.getElementById(`${modalId}_resp`);
if (waitEl) waitEl.style.display = 'none';
if (respEl) {
respEl.style.display = '';
respEl.innerHTML = `
< span style = "color:#9ca3af" > 设备回复:< / span >
< div style = "background:#0f172a;border-radius:6px;padding:8px 12px;margin-top:4px;color:#4ade80;font-family:monospace;white-space:pre-wrap;word-break:break-all" > ${escapeHtml(c.response_content)}< / div >
`;
}
return;
}
} catch (_) {}
}
const waitEl = document.getElementById(`${modalId}_wait`);
if (waitEl) waitEl.innerHTML = '< span style = "color:#6b7280" > < i class = "fas fa-clock" > < / i > 暂无回复(设备可能稍后响应)< / span > ';
})();
2026-03-31 09:41:09 +00:00
}
} catch (err) {
2026-04-01 07:06:37 +00:00
const statusEl = document.getElementById(`${modalId}_status`);
if (statusEl) {
statusEl.innerHTML = `
< div style = "color:#ef4444" > < i class = "fas fa-times-circle" > < / i > 发送失败: ${escapeHtml(err.message)}< / div >
`;
}
}
}
2026-04-01 09:34:42 +00:00
async function _devSetupBtMode(deviceId) {
2026-04-01 07:06:37 +00:00
const dev = cachedDevices.find(d => d.id == deviceId);
const devLabel = dev ? (dev.name || dev.imei) : `设备${deviceId}`;
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-bluetooth-b mr-2 text-purple-400" > < / i > 蓝牙打卡模式 — ${escapeHtml(devLabel)}< / h3 >
< div id = "_btmode_result" style = "font-size:13px" >
< div style = "background:#1e293b;border-radius:8px;padding:12px" >
< div style = "display:flex;align-items:center;gap:6px;color:#a855f7" >
< i class = "fas fa-spinner fa-spin" > < / i > < span > 正在配置蓝牙打卡模式...< / span >
< / div >
< / div >
< / div >
< div class = "flex justify-end mt-4" > < button class = "btn btn-secondary" onclick = "closeModal()" > 关闭< / button > < / div >
`);
try {
2026-04-01 09:34:42 +00:00
const d = await apiCall(`${API_BASE}/beacons/setup-bluetooth-mode?device_ids=${deviceId}`, { method: 'POST' });
2026-04-01 07:06:37 +00:00
const container = document.getElementById('_btmode_result');
if (!container) return;
if (d.error) {
container.innerHTML = `< div style = "background:#1e293b;border-radius:8px;padding:12px;color:#ef4444" > < i class = "fas fa-times-circle" > < / i > ${escapeHtml(d.error)}< / div > `;
return;
}
const info = (d.details || [])[0];
if (!info) { container.innerHTML = '< div style = "color:#6b7280" > 无结果< / div > '; return; }
const statusColor = info.status === '已配置' ? '#22c55e' : info.status === '离线' ? '#6b7280' : '#f59e0b';
const cmdRows = (info.commands || []).map((c, i) => `
< div style = "display:flex;align-items:center;gap:8px;padding:4px 0;${i > 0 ? 'border-top:1px solid #1e293b;' : ''}" >
< span style = "color:${c.ok ? '#22c55e' : '#ef4444'};width:16px;text-align:center" > ${c.ok ? '✓' : '✗'}< / span >
< code style = "color:#60a5fa;font-size:12px" > ${escapeHtml(c.cmd)}< / code >
< / div >
`).join('');
container.innerHTML = `
< div style = "display:flex;align-items:center;gap:8px;margin-bottom:12px" >
< span style = "color:${statusColor};font-weight:600" > < i class = "fas fa-${info.status === '已配置' ? 'check-circle' : info.status === '离线' ? 'minus-circle' : 'exclamation-circle'}" > < / i > ${info.status}< / span >
${info.beacon_count !== undefined ? `< span style = "color:#9ca3af" > · ${info.beacon_count} 个信标MAC< / span > ` : ''}
< / div >
< div style = "background:#0f172a;border-radius:8px;padding:10px 14px" >
< div style = "color:#9ca3af;font-size:11px;margin-bottom:6px" > 已发送指令:< / div >
${cmdRows || '< div style = "color:#6b7280" > 无指令(设备离线)< / div > '}
< / div >
`;
} catch (err) {
const container = document.getElementById('_btmode_result');
if (container) container.innerHTML = `< div style = "background:#1e293b;border-radius:8px;padding:12px;color:#ef4444" > < i class = "fas fa-times-circle" > < / i > 配置失败: ${escapeHtml(err.message)}< / div > `;
2026-03-31 09:41:09 +00:00
}
}
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');
}
}
2026-04-01 07:06:37 +00:00
async function _setupBluetoothMode() {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-bluetooth-b mr-2 text-purple-400" > < / i > 蓝牙打卡模式配置< / h3 >
< div style = "background:#1e293b;border-radius:8px;padding:12px;margin-bottom:16px;font-size:13px;line-height:1.8" >
< p class = "text-gray-300 mb-2" > 将向所有在线设备依次发送以下指令:< / p >
< div style = "color:#a855f7;font-family:monospace" >
< div > 1. < code > CLOCKWAY,3#< / code > — 打卡方式: 手动+蓝牙< / div >
< div > 2. < code > MODE,2#< / code > — 定位模式: 蓝牙< / div >
< div > 3. < code > BTMACSET,MAC...#< / code > — 写入绑定的信标MAC< / div >
< div > 4. < code > BTMP3SW,1#< / code > — 开启语音播报< / div >
< / div >
< p class = "text-yellow-400 mt-2" > < i class = "fas fa-exclamation-triangle" > < / i > 信标MAC来自信标管理页面的设备绑定配置, 未绑定信标的设备将不写入MAC< / p >
< / div >
< div class = "flex gap-3 justify-end" >
< button class = "btn btn-secondary" onclick = "closeModal()" > 取消< / button >
< button class = "btn btn-primary" id = "btnBtModeConfirm" onclick = "_doSetupBluetoothMode()" style = "background:#7c3aed" > < i class = "fas fa-bluetooth-b" > < / i > 确认配置< / button >
< / div >
`);
}
async function _doSetupBluetoothMode() {
const btn = document.getElementById('btnBtModeConfirm');
btn.disabled = true;
btn.innerHTML = '< i class = "fas fa-spinner fa-spin" > < / i > 配置中...';
try {
const result = await apiCall(`${API_BASE}/beacons/setup-bluetooth-mode`, { method: 'POST' });
closeModal();
const d = result;
if (d.error) { showToast(d.error, 'error'); return; }
// Show detail modal
let rows = '';
(d.details || []).forEach(x => {
const label = x.name || x.imei;
const statusColor = x.status === '已配置' ? '#22c55e' : x.status === '离线' ? '#6b7280' : '#f59e0b';
const beaconInfo = x.beacon_count !== undefined ? `${x.beacon_count}个信标` : '';
const cmds = (x.commands || []).map(c =>
`< span style = "color:${c.ok ? '#22c55e' : '#ef4444'};font-family:monospace;font-size:11px" > ${c.ok ? '✓' : '✗'} ${escapeHtml(c.cmd)}< / span > `
).join('< br > ');
rows += `< tr >
< td style = "white-space:nowrap" > ${escapeHtml(String(label))}< / td >
< td style = "color:${statusColor}" > ${x.status}< / td >
< td > ${beaconInfo}< / td >
< td style = "font-size:11px" > ${cmds}< / td >
< / tr > `;
});
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-bluetooth-b mr-2 text-purple-400" > < / i > 蓝牙打卡模式 — 配置结果< / h3 >
< p class = "mb-3" > 共 ${d.total} 台设备: < span class = "text-green-400" > ${d.sent} 台成功< / span > , < span class = "text-red-400" > ${d.failed} 台失败< / span > < / p >
< div style = "max-height:400px;overflow-y:auto" >
< table style = "font-size:12px" > < thead > < tr > < th > 设备< / th > < th > 状态< / th > < th > 信标< / th > < th > 指令详情< / th > < / tr > < / thead > < tbody > ${rows}< / tbody > < / table >
< / div >
< div class = "flex justify-end mt-4" > < button class = "btn btn-primary" onclick = "closeModal()" > 关闭< / button > < / div >
`);
} catch (err) {
showToast('配置失败: ' + err.message, 'error');
}
}
async function _restoreNormalMode() {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-undo mr-2 text-green-400" > < / i > 恢复正常模式< / h3 >
< div style = "background:#1e293b;border-radius:8px;padding:12px;margin-bottom:16px;font-size:13px;line-height:1.8" >
< p class = "text-gray-300 mb-2" > 将向所有在线设备依次发送以下指令:< / p >
< div style = "color:#22c55e;font-family:monospace" >
< div > 1. < code > CLOCKWAY,1#< / code > — 打卡方式: 仅手动打卡< / div >
< div > 2. < code > MODE,3#< / code > — 定位模式: 智能模式< / div >
< div > 3. < code > BTMP3SW,0#< / code > — 关闭语音播报< / div >
< / div >
< / div >
< div class = "flex gap-3 justify-end" >
< button class = "btn btn-secondary" onclick = "closeModal()" > 取消< / button >
< button class = "btn btn-primary" id = "btnRestoreConfirm" onclick = "_doRestoreNormalMode()" style = "background:#16a34a" > < i class = "fas fa-undo" > < / i > 确认恢复< / button >
< / div >
`);
}
async function _doRestoreNormalMode() {
const btn = document.getElementById('btnRestoreConfirm');
btn.disabled = true;
btn.innerHTML = '< i class = "fas fa-spinner fa-spin" > < / i > 恢复中...';
try {
const result = await apiCall(`${API_BASE}/beacons/restore-normal-mode`, { method: 'POST' });
closeModal();
const d = result;
if (d.error) { showToast(d.error, 'error'); return; }
let rows = '';
(d.details || []).forEach(x => {
const label = x.name || x.imei;
const statusColor = x.status === '已恢复' ? '#22c55e' : x.status === '离线' ? '#6b7280' : '#f59e0b';
const cmds = (x.commands || []).map(c =>
`< span style = "color:${c.ok ? '#22c55e' : '#ef4444'};font-family:monospace;font-size:11px" > ${c.ok ? '✓' : '✗'} ${escapeHtml(c.cmd)}< / span > `
).join('< br > ');
rows += `< tr > < td style = "white-space:nowrap" > ${escapeHtml(String(label))}< / td > < td style = "color:${statusColor}" > ${x.status}< / td > < td style = "font-size:11px" > ${cmds}< / td > < / tr > `;
});
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-undo mr-2 text-green-400" > < / i > 恢复正常模式 — 结果< / h3 >
< p class = "mb-3" > 共 ${d.total} 台设备: < span class = "text-green-400" > ${d.sent} 台成功< / span > , < span class = "text-red-400" > ${d.failed} 台失败< / span > < / p >
< div style = "max-height:400px;overflow-y:auto" >
< table style = "font-size:12px" > < thead > < tr > < th > 设备< / th > < th > 状态< / th > < th > 指令详情< / th > < / tr > < / thead > < tbody > ${rows}< / tbody > < / table >
< / div >
< div class = "flex justify-end mt-4" > < button class = "btn btn-primary" onclick = "closeModal()" > 关闭< / button > < / div >
`);
} catch (err) {
showToast('恢复失败: ' + err.message, 'error');
}
}
2026-04-01 09:34:42 +00:00
async function _devRestoreNormal(deviceId) {
2026-04-01 07:06:37 +00:00
const dev = cachedDevices.find(d => d.id == deviceId);
const devLabel = dev ? (dev.name || dev.imei) : `设备${deviceId}`;
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-undo mr-2 text-green-400" > < / i > 恢复正常模式 — ${escapeHtml(devLabel)}< / h3 >
< div id = "_restore_result" style = "font-size:13px" >
< div style = "background:#1e293b;border-radius:8px;padding:12px" >
< div style = "display:flex;align-items:center;gap:6px;color:#22c55e" >
< i class = "fas fa-spinner fa-spin" > < / i > < span > 正在恢复正常模式...< / span >
< / div >
< / div >
< / div >
< div class = "flex justify-end mt-4" > < button class = "btn btn-secondary" onclick = "closeModal()" > 关闭< / button > < / div >
`);
try {
const result = await apiCall(`${API_BASE}/beacons/restore-normal-mode?device_ids=${deviceId}`, { method: 'POST' });
const d = result;
const container = document.getElementById('_restore_result');
if (!container) return;
if (d.error) {
container.innerHTML = `< div style = "background:#1e293b;border-radius:8px;padding:12px;color:#ef4444" > < i class = "fas fa-times-circle" > < / i > ${escapeHtml(d.error)}< / div > `;
return;
}
const info = (d.details || [])[0];
if (!info) { container.innerHTML = '< div style = "color:#6b7280" > 无结果< / div > '; return; }
const statusColor = info.status === '已恢复' ? '#22c55e' : info.status === '离线' ? '#6b7280' : '#f59e0b';
const cmdRows = (info.commands || []).map((c, i) => `
< div style = "display:flex;align-items:center;gap:8px;padding:4px 0;${i > 0 ? 'border-top:1px solid #1e293b;' : ''}" >
< span style = "color:${c.ok ? '#22c55e' : '#ef4444'};width:16px;text-align:center" > ${c.ok ? '✓' : '✗'}< / span >
< code style = "color:#60a5fa;font-size:12px" > ${escapeHtml(c.cmd)}< / code >
< / div >
`).join('');
container.innerHTML = `
< div style = "display:flex;align-items:center;gap:8px;margin-bottom:12px" >
< span style = "color:${statusColor};font-weight:600" > < i class = "fas fa-${info.status === '已恢复' ? 'check-circle' : info.status === '离线' ? 'minus-circle' : 'exclamation-circle'}" > < / i > ${info.status}< / span >
< / div >
< div style = "background:#0f172a;border-radius:8px;padding:10px 14px" >
< div style = "color:#9ca3af;font-size:11px;margin-bottom:6px" > 已发送指令:< / div >
${cmdRows || '< div style = "color:#6b7280" > 无指令(设备离线)< / div > '}
< / div >
`;
} catch (err) {
const container = document.getElementById('_restore_result');
if (container) container.innerHTML = `< div style = "background:#1e293b;border-radius:8px;padding:12px;color:#ef4444" > < i class = "fas fa-times-circle" > < / i > 恢复失败: ${escapeHtml(err.message)}< / div > `;
}
}
2026-03-31 09:41:09 +00:00
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);
}
2026-03-27 10:19:34 +00:00
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, 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 > 设备详情 — ${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.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 != null ? d.battery_level + '%' : '-'} ${d.gsm_signal != null ? ' | 信号: ' + 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 >
<!-- 定位信息 -->
< 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 >
2026-03-27 13:04:11 +00:00
< div style = "font-size:11px;color:#6b7280;margin-bottom:6px" > 定位功能:< / div >
< div style = "display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px" >
${_btn('satellite-dish', 'GPS 开', 'GPSON#', '#0d9488')}
${_btn('satellite-dish', 'GPS 关', 'GPSOFF#', '#991b1b')}
${_btn('crosshairs', '立即定位', 'WHERE#', '#0369a1')}
< / div >
< div style = "font-size:11px;color:#6b7280;margin-bottom:6px" > 蓝牙功能:< / div >
2026-03-27 10:19:34 +00:00
< div style = "display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px" >
2026-03-27 13:04:11 +00:00
${_btn('bluetooth-b', '蓝牙 开', 'BTON#', '#2563eb')}
${_btn('bluetooth-b', '蓝牙 关', 'BTOFF#', '#991b1b')}
${_btn('broadcast-tower', 'BLE扫描 开', 'BTSCAN,1#', '#7c3aed')}
${_btn('broadcast-tower', 'BLE扫描 关', 'BTSCAN,0#', '#991b1b')}
2026-03-27 10:19:34 +00:00
< / div >
2026-03-27 13:04:11 +00:00
< div style = "font-size:11px;color:#6b7280;margin-bottom:6px" > 工作模式:< / div >
2026-03-27 10:19:34 +00:00
< div style = "display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px" >
${_btn('clock', '定时定位', 'MODE,1#', '#4b5563')}
${_btn('brain', '智能模式', 'MODE,3#', '#4b5563')}
< / div >
2026-03-27 13:04:11 +00:00
< div style = "font-size:11px;color:#6b7280;margin-bottom:6px" > 上报间隔:< / div >
< div style = "display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px" >
${_btn('stopwatch', '10秒', 'TIMER,10#', '#374151')}
${_btn('stopwatch', '30秒', 'TIMER,30#', '#374151')}
${_btn('stopwatch', '60秒', 'TIMER,60#', '#374151')}
${_btn('stopwatch', '5分钟', 'TIMER,300#', '#374151')}
${_btn('stopwatch', '10分钟', 'TIMER,600#', '#374151')}
< / div >
< div style = "display:flex;gap:6px;margin-bottom:10px;align-items:center" >
< input id = "detailTimerInput" type = "number" placeholder = "自定义秒数" min = "5" max = "86400" style = "width:120px;background:#1f2937;border:1px solid #374151;border-radius:6px;padding:5px 8px;color:#e5e7eb;font-size:12px" $ { online ? ' ' : ' disabled ' } >
< button class = "btn" style = "font-size:12px;padding:5px 10px;background:#0d9488;border:none;color:#fff;border-radius:6px" $ { disabledAttr } onclick = "const v=document.getElementById('detailTimerInput').value;if(v&&v>=5)_quickCmd('${did}','TIMER,'+v+'#',this);else showToast('请输入5~86400秒','error')" > < i class = "fas fa-paper-plane" > < / i > 设置< / button >
< / div >
< div style = "font-size:11px;color:#6b7280;margin-bottom:6px" > SOS 号码 (最多3个, 逗号分隔):< / div >
< div style = "display:flex;gap:6px;margin-bottom:10px;align-items:center" >
< input id = "detailSosInput" type = "text" placeholder = "如: 13800001111,13800002222" maxlength = "60" style = "flex:1;background:#1f2937;border:1px solid #374151;border-radius:6px;padding:5px 8px;color:#e5e7eb;font-size:12px" $ { online ? ' ' : ' disabled ' } >
< button class = "btn" style = "font-size:12px;padding:5px 10px;background:#dc2626;border:none;color:#fff;border-radius:6px" $ { disabledAttr } onclick = "const v=document.getElementById('detailSosInput').value.trim();if(v)_quickCmd('${did}','SOS,A,'+v+'#',this);else showToast('请输入号码','error')" > < i class = "fas fa-phone" > < / i > 设置SOS< / button >
< button class = "btn" style = "font-size:12px;padding:5px 10px;background:#991b1b;border:none;color:#fff;border-radius:6px" $ { disabledAttr } onclick = "if(confirm('确定清空所有SOS号码? '))_quickCmd('${did}','SOS,D,1,2,3#',this)" > < i class = "fas fa-trash" > < / i > 清空< / button >
< / div >
2026-03-27 10:19:34 +00:00
< 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')}
2026-03-27 13:04:11 +00:00
${_btn('stopwatch', '定时器查询', 'TIMER#', '#374151')}
2026-03-27 10:19:34 +00:00
${_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 >
`);
} catch (err) {
showToast('加载设备详情失败: ' + err.message, 'error');
}
}
async function showEditDeviceModal(id) {
try {
const d = await apiCall(`${API_BASE}/devices/${id}`);
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-edit mr-2 text-yellow-400" > < / i > 编辑设备< / h3 >
< div class = "form-group" > < label > 名称< / label > < input type = "text" id = "editDeviceName" value = "${escapeHtml(d.name || '')}" > < / div >
< div class = "form-group" > < label > 状态< / label >
< select id = "editDeviceStatus" >
< option value = "online" $ { d . status = == ' online ' ? ' selected ' : ' ' } > 在线< / option >
< option value = "offline" $ { d . status = == ' offline ' ? ' selected ' : ' ' } > 离线< / option >
< / select >
< / div >
< div class = "form-group" > < label > 时区< / label > < input type = "text" id = "editDeviceTimezone" value = "${escapeHtml(d.timezone || '')}" > < / div >
< div class = "form-group" > < label > 语言< / label > < input type = "text" id = "editDeviceLang" value = "${escapeHtml(d.language || '')}" > < / div >
< div class = "flex gap-3 mt-6" >
< button class = "btn btn-primary flex-1" onclick = "submitEditDevice('${id}')" > < i class = "fas fa-check" > < / i > 保存< / button >
< button class = "btn btn-secondary flex-1" onclick = "closeModal()" > < i class = "fas fa-times" > < / i > 取消< / button >
< / div >
`);
} catch (err) {
showToast('加载设备信息失败: ' + err.message, 'error');
}
}
async function submitEditDevice(id) {
const body = {};
const name = document.getElementById('editDeviceName').value.trim();
const status = document.getElementById('editDeviceStatus').value;
const timezone = document.getElementById('editDeviceTimezone').value.trim();
const language = document.getElementById('editDeviceLang').value.trim();
if (name !== '') body.name = name;
if (status) body.status = status;
if (timezone) body.timezone = timezone;
if (language) body.language = language;
try {
await apiCall(`${API_BASE}/devices/${id}`, { method: 'PUT', body: JSON.stringify(body) });
showToast('设备更新成功');
closeModal();
loadDevices();
} catch (err) {
showToast('更新失败: ' + err.message, 'error');
}
}
function confirmDeleteDevice(id, name) {
showModal(`
< h3 class = "text-lg font-semibold mb-4 text-red-400" > < i class = "fas fa-exclamation-triangle mr-2" > < / i > 确认删除< / h3 >
< p class = "text-gray-300 mb-6" > 确定要删除设备 < strong > "${name}"< / strong > 吗?此操作不可恢复。< / p >
< div class = "flex gap-3" >
< button class = "btn btn-danger flex-1" onclick = "deleteDevice('${id}')" > < i class = "fas fa-trash" > < / i > 确认删除< / button >
< button class = "btn btn-secondary flex-1" onclick = "closeModal()" > < i class = "fas fa-times" > < / i > 取消< / button >
< / div >
`);
}
async function deleteDevice(id) {
try {
await apiCall(`${API_BASE}/devices/${id}`, { method: 'DELETE' });
showToast('设备已删除');
closeModal();
loadDevices();
} catch (err) {
showToast('删除失败: ' + err.message, 'error');
}
}
2026-03-27 13:04:11 +00:00
// ==================== MAP COORDINATE CONVERSION ====================
// WGS-84 → GCJ-02 (高德 JS API uses GCJ-02 natively)
2026-03-27 10:19:34 +00:00
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];
}
function gcj02ToWgs84(gcjLat, gcjLng) {
if (_outOfChina(gcjLat, gcjLng)) return [gcjLat, gcjLng];
let dLat = _transformLat(gcjLng - 105.0, gcjLat - 35.0);
let dLng = _transformLng(gcjLng - 105.0, gcjLat - 35.0);
const radLat = gcjLat / 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 [gcjLat - dLat, gcjLng - dLng];
}
2026-03-27 13:04:11 +00:00
// Convert WGS-84 coords to GCJ-02 for 高德地图
2026-03-27 10:19:34 +00:00
function toMapCoord(lat, lng) {
2026-03-27 13:04:11 +00:00
return wgs84ToGcj02(lat, lng);
2026-03-27 10:19:34 +00:00
}
2026-03-31 09:41:09 +00:00
// ==================== 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;
2026-04-01 07:06:37 +00:00
const todayStr = new Date().toISOString().split('T')[0];
document.getElementById('ovTrackStart').value = todayStr;
document.getElementById('ovTrackEnd').value = todayStr;
2026-03-31 09:41:09 +00:00
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; }
2026-04-01 07:06:37 +00:00
const startDate = document.getElementById('ovTrackStart').value;
const endDate = document.getElementById('ovTrackEnd').value;
if (!startDate || !endDate) { showToast('请选择日期范围', 'error'); return; }
if (startDate > endDate) { showToast('开始日期不能晚于结束日期', 'error'); return; }
2026-03-31 09:41:09 +00:00
_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 {
2026-04-01 07:06:37 +00:00
const data = await apiCall(`${API_BASE}/locations/track/${did}?start_time=${startDate}T00:00:00&end_time=${endDate}T23:59:59`);
2026-03-31 09:41:09 +00:00
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);
}
}
2026-04-01 07:06:37 +00:00
if (totalPoints === 0) { showToast('所选日期范围内没有轨迹数据', 'info'); return; }
2026-03-31 09:41:09 +00:00
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();
}
2026-03-27 10:19:34 +00:00
// ==================== LOCATIONS ====================
function initLocationMap() {
if (locationMap) return;
setTimeout(() => {
2026-03-27 13:04:11 +00:00
const [mLat, mLng] = toMapCoord(30.605, 103.936);
locationMap = new AMap.Map('locationMap', {
viewMode: '3D',
pitch: 45,
rotation: -15,
rotateEnable: true,
pitchEnable: true,
zoom: 10,
zooms: [2, 20],
center: [mLng, mLat],
mapStyle: 'amap://styles/normal',
});
2026-03-27 10:19:34 +00:00
}, 100);
}
function clearMapOverlays() {
2026-03-30 09:41:55 +00:00
mapMarkers.forEach(m => { if (!m._lpHidden) locationMap.remove(m); });
2026-03-27 10:19:34 +00:00
mapMarkers = [];
2026-03-27 13:04:11 +00:00
mapInfoWindows = [];
if (mapPolyline) { locationMap.remove(mapPolyline); mapPolyline = null; }
2026-03-30 09:41:55 +00:00
_trackPolyline = null;
_trackLocations = null;
2026-03-27 13:04:11 +00:00
if (trackPlayTimer) { cancelAnimationFrame(trackPlayTimer); trackPlayTimer = null; }
if (trackMovingMarker) { locationMap.remove(trackMovingMarker); trackMovingMarker = null; }
2026-03-30 04:26:29 +00:00
if (_focusInfoWindow) { _focusInfoWindow.close(); _focusInfoWindow = null; }
if (_focusMarker) { locationMap.remove(_focusMarker); _focusMarker = null; }
const legend = document.getElementById('mapLegend');
if (legend) legend.style.display = 'none';
2026-03-27 10:19:34 +00:00
}
2026-03-30 09:41:55 +00:00
// ---- Hide low-precision toggle ----
let _hideLowPrecision = false;
function toggleHideLowPrecision() {
_hideLowPrecision = !_hideLowPrecision;
const btn = document.getElementById('btnHideLowPrecision');
if (_hideLowPrecision) {
btn.style.background = '#b91c1c';
btn.style.color = '#fff';
btn.innerHTML = '< i class = "fas fa-eye-slash" > < / i > 低精度';
} else {
btn.style.background = '';
btn.style.color = '';
btn.innerHTML = '< i class = "fas fa-eye" > < / i > 低精度';
}
2026-03-31 09:41:09 +00:00
// Re-apply to existing track markers + polyline
2026-03-30 09:41:55 +00:00
_applyLowPrecisionFilter();
2026-03-31 09:41:09 +00:00
// Reload table with correct pagination
loadLocationRecords(1);
2026-03-30 09:41:55 +00:00
}
function _isLowPrecision(locationType) {
const t = (locationType || '').toLowerCase();
2026-03-31 09:41:09 +00:00
return t.startsWith('lbs');
2026-03-30 09:41:55 +00:00
}
function _applyLowPrecisionFilter() {
// Toggle visibility of low-precision markers stored with _lpFlag
mapMarkers.forEach(m => {
if (m._lpFlag) {
if (_hideLowPrecision) { m.hide ? m.hide() : m.setMap(null); m._lpHidden = true; }
else { m.show ? m.show() : m.setMap(locationMap); m._lpHidden = false; }
}
});
// Also filter the track polyline if exists — rebuild path without LP points
if (_trackPolyline & & _trackLocations) {
const path = [];
_trackLocations.forEach(loc => {
if (_hideLowPrecision & & _isLowPrecision(loc.location_type)) return;
const lat = loc.latitude || loc.lat;
const lng = loc.longitude || loc.lng || loc.lon;
if (lat & & lng) {
const [mLat, mLng] = toMapCoord(lat, lng);
path.push([mLng, mLat]);
}
});
_trackPolyline.setPath(path);
}
// Filter table rows
document.querySelectorAll('#locationsTableBody tr[data-loc-type]').forEach(tr => {
if (_hideLowPrecision & & _isLowPrecision(tr.dataset.locType)) {
tr.style.display = 'none';
} else {
tr.style.display = '';
}
});
}
let _trackPolyline = null;
let _trackLocations = null;
2026-03-27 10:19:34 +00:00
async function loadTrack() {
const deviceId = document.getElementById('locDeviceSelect').value;
if (!deviceId) { showToast('请选择设备', 'error'); return; }
let startTime = document.getElementById('locStartDate').value;
let endTime = document.getElementById('locEndDate').value;
// Default to today if not specified
if (!startTime) {
const today = new Date();
startTime = today.toISOString().split('T')[0];
document.getElementById('locStartDate').value = startTime;
}
if (!endTime) {
const today = new Date();
endTime = today.toISOString().split('T')[0];
document.getElementById('locEndDate').value = endTime;
}
let url = `${API_BASE}/locations/track/${deviceId}?start_time=${startTime}T00:00:00&end_time=${endTime}T23:59:59`;
try {
showToast('正在加载轨迹...', 'info');
const data = await apiCall(url);
const locations = Array.isArray(data) ? data : (data.items || []);
if (!locationMap) initLocationMap();
clearMapOverlays();
if (locations.length === 0) {
showToast('该时间段没有轨迹数据', 'info');
return;
}
2026-03-27 13:04:11 +00:00
const path = [];
const total = locations.length;
2026-03-27 10:19:34 +00:00
locations.forEach((loc, i) => {
const lat = loc.latitude || loc.lat;
const lng = loc.longitude || loc.lng || loc.lon;
if (lat & & lng) {
const [mLat, mLng] = toMapCoord(lat, lng);
2026-03-27 13:04:11 +00:00
path.push([mLng, mLat]);
2026-03-27 10:19:34 +00:00
const isFirst = i === 0;
2026-03-27 13:04:11 +00:00
const isLast = i === total - 1;
2026-03-30 04:26:29 +00:00
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';
2026-03-27 13:04:11 +00:00
const marker = new AMap.CircleMarker({
center: [mLng, mLat],
2026-03-30 04:26:29 +00:00
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',
2026-03-27 13:04:11 +00:00
});
2026-03-31 09:41:09 +00:00
const isLP = isLbs & & !isFirst & & !isLast;
2026-03-30 09:41:55 +00:00
marker._lpFlag = isLP;
if (_hideLowPrecision & & isLP) marker._lpHidden = true;
else marker.setMap(locationMap);
2026-03-27 13:04:11 +00:00
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) });
2026-03-31 09:41:09 +00:00
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; });
2026-03-27 10:19:34 +00:00
mapMarkers.push(marker);
2026-03-27 13:04:11 +00:00
mapInfoWindows.push({ infoWindow, position: [mLng, mLat], locId: loc.id });
2026-03-27 10:19:34 +00:00
}
});
2026-03-30 09:41:55 +00:00
// Store track data for LP filtering
_trackLocations = locations;
// Build path excluding LP points if hidden
const filteredPath = _hideLowPrecision
? path.filter((_, i) => {
const lt = (locations[i]?.location_type || '').toLowerCase();
2026-03-31 09:41:09 +00:00
return !lt.startsWith('lbs');
2026-03-30 09:41:55 +00:00
})
: path;
if (filteredPath.length > 1) {
mapPolyline = new AMap.Polyline({ path: filteredPath, strokeColor: '#3b82f6', strokeWeight: 3, strokeOpacity: 0.8, lineJoin: 'round' });
2026-03-27 13:04:11 +00:00
mapPolyline.setMap(locationMap);
2026-03-30 09:41:55 +00:00
_trackPolyline = mapPolyline;
2026-03-27 13:04:11 +00:00
locationMap.setFitView([mapPolyline], false, [50, 50, 50, 50]);
2026-03-30 09:41:55 +00:00
} else if (filteredPath.length === 1) {
locationMap.setCenter(filteredPath[0]);
2026-03-30 04:26:29 +00:00
locationMap.setZoom(15);
2026-03-27 10:19:34 +00:00
}
2026-03-30 04:26:29 +00:00
const legend = document.getElementById('mapLegend');
if (legend) legend.style.display = 'block';
2026-03-30 09:41:55 +00:00
showToast(`已加载 ${total} 个轨迹点${_hideLowPrecision ? ' (已隐藏低精度)' : ''}`);
2026-03-31 10:11:33 +00:00
// Load track summary
try {
const summary = await apiCall(`${API_BASE}/locations/track-summary/${deviceId}?start_time=${startTime}T00:00:00&end_time=${endTime}T23:59:59`);
if (summary & & summary.point_count > 0) {
const dist = summary.total_distance_km != null ? summary.total_distance_km.toFixed(2) + 'km' : '-';
const dur = summary.duration_minutes != null ? Math.round(summary.duration_minutes) + '分钟' : '-';
const spd = summary.avg_speed_kmh != null ? summary.avg_speed_kmh.toFixed(1) + 'km/h' : '-';
showToast(`轨迹摘要: ${summary.point_count}点, ${dist}, ${dur}, 均速${spd}`, 'info');
}
} catch(e) { console.warn('Track summary failed:', e); }
2026-03-27 10:19:34 +00:00
} catch (err) {
showToast('加载轨迹失败: ' + err.message, 'error');
}
}
async function loadLatestPosition() {
const deviceId = document.getElementById('locDeviceSelect').value;
if (!deviceId) { showToast('请选择设备', 'error'); return; }
2026-03-30 04:26:29 +00:00
const btn = document.querySelector('.btn-success');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '< i class = "fas fa-spinner fa-spin" > < / i > 获取中...';
2026-03-27 10:19:34 +00:00
try {
2026-03-30 04:26:29 +00:00
// Record timestamp before sending command
const sentAt = new Date().toISOString();
2026-03-27 13:04:11 +00:00
// Send WHERE# to request fresh position from device
try {
await apiCall(`${API_BASE}/commands/send`, {
method: 'POST',
body: JSON.stringify({ device_id: parseInt(deviceId), command_type: 'online_cmd', command_content: 'WHERE#' }),
});
showToast('已发送定位指令,等待设备回传...', 'info');
2026-03-30 04:26:29 +00:00
} 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;
}
2026-03-27 13:04:11 +00:00
2026-03-27 10:19:34 +00:00
if (!locationMap) initLocationMap();
clearMapOverlays();
const lat = loc.latitude || loc.lat;
const lng = loc.longitude || loc.lng || loc.lon;
2026-03-30 04:26:29 +00:00
if (!lat || !lng) { showToast('设备回传了数据但无有效坐标', 'info'); return; }
2026-03-27 10:19:34 +00:00
const [mLat, mLng] = toMapCoord(lat, lng);
2026-03-27 13:04:11 +00:00
const marker = new AMap.Marker({ position: [mLng, mLat] });
marker.setMap(locationMap);
2026-03-30 04:26:29 +00:00
const infoContent = _buildInfoContent('实时定位', loc, lat, lng);
2026-03-27 13:04:11 +00:00
const infoWindow = new AMap.InfoWindow({ content: infoContent, offset: new AMap.Pixel(0, -30) });
infoWindow.open(locationMap, [mLng, mLat]);
2026-03-31 09:41:09 +00:00
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; });
2026-03-27 10:19:34 +00:00
mapMarkers.push(marker);
2026-03-30 04:26:29 +00:00
locationMap.setCenter([mLng, mLat]);
locationMap.setZoom(15);
showToast('已获取设备实时位置');
2026-03-27 10:19:34 +00:00
loadLocationRecords(1);
} catch (err) {
2026-03-30 04:26:29 +00:00
showToast('获取位置失败: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
2026-03-27 10:19:34 +00:00
}
}
async function loadLocationRecords(page) {
if (page) pageState.locations.page = page;
const p = pageState.locations.page;
const ps = pageState.locations.pageSize;
const deviceId = document.getElementById('locDeviceSelect').value;
const locType = document.getElementById('locTypeFilter').value;
const startTime = document.getElementById('locStartDate').value;
const endTime = document.getElementById('locEndDate').value;
let url = `${API_BASE}/locations?page=${p}&page_size=${ps}`;
if (deviceId) url += `&device_id=${deviceId}`;
if (locType) url += `&location_type=${locType}`;
2026-03-31 09:41:09 +00:00
if (_hideLowPrecision & & !locType) url += `&exclude_type=lbs`;
2026-03-27 10:19:34 +00:00
if (startTime) url += `&start_time=${startTime}T00:00:00`;
if (endTime) url += `&end_time=${endTime}T23:59:59`;
showLoading('locationsLoading');
try {
const data = await apiCall(url);
const items = data.items || [];
2026-03-30 04:26:29 +00:00
_locTableItems = items; // cache for focusMapPoint
2026-03-27 10:19:34 +00:00
const tbody = document.getElementById('locationsTableBody');
if (items.length === 0) {
2026-03-30 04:26:29 +00:00
tbody.innerHTML = '< tr > < td colspan = "10" class = "text-center text-gray-500 py-8" > 没有位置记录< / td > < / tr > ';
2026-03-27 10:19:34 +00:00
} else {
2026-03-27 13:04:11 +00:00
tbody.innerHTML = items.map(l => {
const q = _locQuality(l);
const hasCoord = l.latitude != null & & l.longitude != null;
2026-03-31 09:41:09 +00:00
return `< tr data-loc-type = "${l.location_type || ''}" style = "cursor:${hasCoord?'pointer':'default'}" $ { hasCoord ? ` onclick = "focusMapPoint(${l.id})" ` : ' ' } >
2026-03-30 04:26:29 +00:00
< td onclick = "event.stopPropagation()" > < input type = "checkbox" class = "loc-sel-cb" value = "${l.id}" onchange = "updateLocSelCount()" > < / td >
2026-03-31 09:41:09 +00:00
< td class = "font-mono text-xs" > ${escapeHtml(_imei(l.device_id))}< / td >
2026-03-27 13:04:11 +00:00
< td > ${_locTypeLabel(l.location_type)}< / td >
2026-03-27 10:19:34 +00:00
< td > ${l.latitude != null ? Number(l.latitude).toFixed(6) : '-'}< / td >
< td > ${l.longitude != null ? Number(l.longitude).toFixed(6) : '-'}< / td >
< td class = "text-xs" > ${escapeHtml(l.address || '-')}< / td >
< td > ${l.speed != null ? l.speed : '-'}< / td >
2026-03-27 13:04:11 +00:00
< td > < span class = "badge" style = "background:${q.bg};color:${q.fg}" > ${q.label}< / span > < / td >
2026-03-27 10:19:34 +00:00
< td class = "text-xs text-gray-400" > ${formatTime(l.recorded_at || l.created_at)}< / td >
2026-03-27 13:04:11 +00:00
< td >
${hasCoord ? `< button class = "btn btn-sm" style = "color:#3b82f6;font-size:11px" onclick = "event.stopPropagation();focusMapPoint(${l.id})" title = "定位" > < i class = "fas fa-map-marker-alt" > < / i > < / button > ` : ''}
< button class = "btn btn-sm" style = "color:#ef4444;font-size:11px" onclick = "event.stopPropagation();deleteLocationRecord(${l.id})" title = "删除" > < i class = "fas fa-trash" > < / i > < / button >
< / td >
< / tr > `;
}).join('');
2026-03-27 10:19:34 +00:00
}
2026-03-30 04:26:29 +00:00
document.getElementById('locSelectAll').checked = false;
updateLocSelCount();
2026-03-27 10:19:34 +00:00
renderPagination('locationsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadLocationRecords');
} catch (err) {
showToast('加载位置记录失败: ' + err.message, 'error');
2026-03-30 04:26:29 +00:00
document.getElementById('locationsTableBody').innerHTML = '< tr > < td colspan = "10" class = "text-center text-red-400 py-8" > 加载失败< / td > < / tr > ';
2026-03-27 10:19:34 +00:00
} finally {
hideLoading('locationsLoading');
}
}
// ==================== ALARMS ====================
async function loadAlarmStats() {
try {
const stats = await apiCall(`${API_BASE}/alarms/stats`);
document.getElementById('alarmStatTotal').textContent = stats.total || 0;
document.getElementById('alarmStatUnack').textContent = stats.unacknowledged || 0;
document.getElementById('alarmStatAck').textContent = stats.acknowledged || 0;
2026-03-31 10:11:33 +00:00
const alarmTodayEl = document.getElementById('alarmStatToday');
if (alarmTodayEl) alarmTodayEl.textContent = `今日 ${stats.today || 0}`;
2026-03-27 10:19:34 +00:00
renderAlarmDoughnut('alarmTypeChart', stats.by_type, true);
} catch (err) {
console.error('Failed to load alarm stats:', err);
}
}
async function loadAlarms(page) {
if (page) pageState.alarms.page = page;
const p = pageState.alarms.page;
const ps = pageState.alarms.pageSize;
const deviceId = document.getElementById('alarmDeviceFilter').value;
const alarmType = document.getElementById('alarmTypeFilter').value;
const ack = document.getElementById('alarmAckFilter').value;
const startTime = document.getElementById('alarmStartDate').value;
const endTime = document.getElementById('alarmEndDate').value;
let url = `${API_BASE}/alarms?page=${p}&page_size=${ps}`;
if (deviceId) url += `&device_id=${deviceId}`;
if (alarmType) url += `&alarm_type=${alarmType}`;
if (ack) url += `&acknowledged=${ack}`;
if (startTime) url += `&start_time=${startTime}T00:00:00`;
if (endTime) url += `&end_time=${endTime}T23:59:59`;
showLoading('alarmsLoading');
try {
const data = await apiCall(url);
const items = data.items || [];
const tbody = document.getElementById('alarmsTableBody');
if (items.length === 0) {
2026-03-31 02:03:21 +00:00
tbody.innerHTML = '< tr > < td colspan = "10" class = "text-center text-gray-500 py-8" > 没有告警记录< / td > < / tr > ';
2026-03-27 10:19:34 +00:00
} else {
tbody.innerHTML = items.map(a => `
< tr >
2026-03-31 10:11:33 +00:00
< td onclick = "event.stopPropagation()" > < input type = "checkbox" class = "alarm-sel-cb" value = "${a.id}" onchange = "updateSelCount('alarm-sel-cb','alarmSelCount','btnBatchDeleteAlarm');updateSelCount('alarm-sel-cb','alarmAckCount','btnBatchAckAlarm')" > < / td >
2026-03-31 09:41:09 +00:00
< td class = "font-mono text-xs" > ${escapeHtml(_imei(a.device_id))}< / td >
2026-03-27 10:19:34 +00:00
< td > < span class = "${alarmTypeClass(a.alarm_type)} font-semibold" > ${alarmTypeName(a.alarm_type)}< / span > < / td >
2026-03-31 05:01:04 +00:00
< 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 >
2026-03-27 10:19:34 +00:00
< td class = "text-xs" > ${a.address ? escapeHtml(a.address) : (a.latitude != null ? Number(a.latitude).toFixed(6) + ', ' + Number(a.longitude).toFixed(6) : '-')}< / td >
< td > ${a.battery_level != null ? a.battery_level + '%' : '-'}< / td >
< td > ${a.gsm_signal != null ? a.gsm_signal : '-'}< / td >
< td > ${a.acknowledged ? '< span class = "badge badge-online" > 已确认< / span > ' : '< span class = "badge badge-offline" > 未确认< / span > '}< / td >
< td class = "text-xs text-gray-400" > ${formatTime(a.recorded_at)}< / td >
< td >
${a.acknowledged
? `< button class = "btn btn-warning btn-sm" onclick = "toggleAlarmAck('${a.id}', false)" > < i class = "fas fa-undo" > < / i > < / button > `
: `< button class = "btn btn-success btn-sm" onclick = "toggleAlarmAck('${a.id}', true)" > < i class = "fas fa-check" > < / i > < / button > `
}
< / td >
< / tr >
`).join('');
}
2026-03-31 02:03:21 +00:00
document.getElementById('alarmSelectAll').checked = false;
2026-03-27 10:19:34 +00:00
renderPagination('alarmsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadAlarms');
} catch (err) {
showToast('加载告警失败: ' + err.message, 'error');
2026-03-31 02:03:21 +00:00
document.getElementById('alarmsTableBody').innerHTML = '< tr > < td colspan = "10" class = "text-center text-red-400 py-8" > 加载失败< / td > < / tr > ';
2026-03-27 10:19:34 +00:00
} finally {
hideLoading('alarmsLoading');
}
}
async function toggleAlarmAck(id, acknowledged) {
try {
await apiCall(`${API_BASE}/alarms/${id}/acknowledge`, {
method: 'PUT',
body: JSON.stringify({ acknowledged }),
});
showToast(acknowledged ? '告警已确认' : '告警已取消确认');
2026-03-31 05:01:04 +00:00
// Update row in-place instead of full reload
const cb = document.querySelector(`.alarm-sel-cb[value="${id}"]`);
if (cb) {
const tr = cb.closest('tr');
const cells = tr.querySelectorAll('td');
// col 7 = acknowledged badge, col 9 = action button
if (cells[7]) cells[7].innerHTML = acknowledged ? '< span class = "badge badge-online" > 已确认< / span > ' : '< span class = "badge badge-offline" > 未确认< / span > ';
if (cells[9]) cells[9].innerHTML = acknowledged
? `< button class = "btn btn-warning btn-sm" onclick = "toggleAlarmAck('${id}', false)" > < i class = "fas fa-undo" > < / i > < / button > `
: `< button class = "btn btn-success btn-sm" onclick = "toggleAlarmAck('${id}', true)" > < i class = "fas fa-check" > < / i > < / button > `;
}
2026-03-27 10:19:34 +00:00
loadAlarmStats();
} catch (err) {
showToast('操作失败: ' + err.message, 'error');
}
}
2026-03-31 10:11:33 +00:00
async function batchAcknowledgeAlarms() {
const cbs = document.querySelectorAll('.alarm-sel-cb:checked');
if (!cbs.length) return;
const ids = [...cbs].map(c => parseInt(c.value));
if (!confirm(`确认批量确认 ${ids.length} 条告警?`)) return;
try {
await apiCall(`${API_BASE}/alarms/batch-acknowledge`, {
method: 'POST',
body: JSON.stringify({ alarm_ids: ids, acknowledged: true }),
});
showToast(`已批量确认 ${ids.length} 条告警`, 'success');
loadAlarms();
loadAlarmStats();
} catch (err) {
showToast('批量确认失败: ' + err.message, 'error');
}
}
2026-03-27 10:19:34 +00:00
// ==================== ATTENDANCE ====================
async function loadAttendanceStats() {
try {
const deviceId = document.getElementById('attDeviceFilter')?.value || '';
const startTime = document.getElementById('attStartDate')?.value || '';
const endTime = document.getElementById('attEndDate')?.value || '';
let url = `${API_BASE}/attendance/stats?`;
if (deviceId) url += `device_id=${deviceId}&`;
if (startTime) url += `start_time=${startTime}T00:00:00&`;
if (endTime) url += `end_time=${endTime}T23:59:59&`;
const stats = await apiCall(url);
document.getElementById('attStatTotal').textContent = stats.total || 0;
document.getElementById('attStatCheckIn').textContent = stats.by_type?.clock_in || stats.check_in || 0;
document.getElementById('attStatCheckOut').textContent = stats.by_type?.clock_out || stats.check_out || 0;
const other = stats.total - (stats.by_type?.clock_in || 0) - (stats.by_type?.clock_out || 0);
document.getElementById('attStatOther').textContent = other > 0 ? other : 0;
2026-03-31 10:11:33 +00:00
const attTodayEl = document.getElementById('attStatToday');
if (attTodayEl) attTodayEl.textContent = `今日 ${stats.today || 0}`;
2026-03-27 10:19:34 +00:00
} catch (err) {
console.error('Failed to load attendance stats:', err);
}
}
async function loadAttendance(page) {
if (page) pageState.attendance.page = page;
const p = pageState.attendance.page;
const ps = pageState.attendance.pageSize;
const deviceId = document.getElementById('attDeviceFilter').value;
const attType = document.getElementById('attTypeFilter').value;
2026-03-30 04:26:29 +00:00
const attSource = document.getElementById('attSourceFilter').value;
2026-03-27 10:19:34 +00:00
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}`;
2026-03-30 04:26:29 +00:00
if (attSource) url += `&attendance_source=${attSource}`;
2026-03-27 10:19:34 +00:00
if (startTime) url += `&start_time=${startTime}T00:00:00`;
if (endTime) url += `&end_time=${endTime}T23:59:59`;
showLoading('attendanceLoading');
try {
const data = await apiCall(url);
const items = data.items || [];
const tbody = document.getElementById('attendanceTableBody');
if (items.length === 0) {
2026-03-31 02:03:21 +00:00
tbody.innerHTML = '< tr > < td colspan = "9" class = "text-center text-gray-500 py-8" > 没有考勤记录< / td > < / tr > ';
2026-03-27 10:19:34 +00:00
} else {
tbody.innerHTML = items.map(a => {
const posStr = a.address || (a.latitude != null ? `${Number(a.latitude).toFixed(6)}, ${Number(a.longitude).toFixed(6)}` : '-');
2026-03-31 05:01:04 +00:00
const locMethod = a.gps_positioned ? '< span style = "color:#22c55e;font-size:11px" > < i class = "fas fa-satellite mr-1" > < / i > GPS< / span > ' : (a.wifi_data ? '< span style = "color:#f59e0b;font-size:11px" > < i class = "fas fa-wifi mr-1" > < / i > WiFi< / span > ' : '< span style = "color:#f97316;font-size:11px" > < i class = "fas fa-broadcast-tower mr-1" > < / i > LBS< / span > ');
2026-03-27 10:19:34 +00:00
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}` : '-';
2026-03-30 04:26:29 +00:00
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';
2026-03-27 10:19:34 +00:00
return `< tr >
2026-03-31 02:03:21 +00:00
< td onclick = "event.stopPropagation()" > < input type = "checkbox" class = "att-sel-cb" value = "${a.id}" onchange = "updateSelCount('att-sel-cb','attSelCount','btnBatchDeleteAtt')" > < / td >
2026-03-31 09:41:09 +00:00
< td class = "font-mono text-xs" > ${escapeHtml(_imei(a.device_id))}< / td >
2026-03-27 10:19:34 +00:00
< td > < span class = "${a.attendance_type === 'clock_in' ? 'text-green-400' : 'text-blue-400'} font-semibold" > ${attendanceTypeName(a.attendance_type)}< / span > < / td >
2026-03-30 04:26:29 +00:00
< td style = "color:${srcColor};font-size:12px" > ${srcLabel}< / td >
2026-03-31 05:01:04 +00:00
< td class = "text-xs" > ${locMethod} ${escapeHtml(posStr)}< / td >
2026-03-27 10:19:34 +00:00
< td class = "text-xs" > ${battStr} ${sigStr}< / td >
< td class = "text-xs font-mono" > ${lbsStr}< / td >
< td class = "text-xs text-gray-400" > ${formatTime(a.recorded_at)}< / td >
2026-03-30 09:41:55 +00:00
< td > < button class = "btn btn-sm" style = "color:#ef4444;padding:2px 8px;font-size:11px" onclick = "deleteAttendance(${a.id})" > < i class = "fas fa-trash-alt" > < / i > < / button > < / td >
2026-03-27 10:19:34 +00:00
< / tr > `;
}).join('');
}
2026-03-31 02:03:21 +00:00
document.getElementById('attSelectAll').checked = false;
2026-03-27 10:19:34 +00:00
renderPagination('attendancePagination', data.total || 0, data.page || p, data.page_size || ps, 'loadAttendance');
} catch (err) {
showToast('加载考勤记录失败: ' + err.message, 'error');
2026-03-31 02:03:21 +00:00
document.getElementById('attendanceTableBody').innerHTML = '< tr > < td colspan = "9" class = "text-center text-red-400 py-8" > 加载失败< / td > < / tr > ';
2026-03-27 10:19:34 +00:00
} finally {
hideLoading('attendanceLoading');
}
}
2026-03-30 09:41:55 +00:00
async function deleteAttendance(id) {
if (!confirm('确认删除此考勤记录?')) return;
try {
await apiCall(`${API_BASE}/attendance/${id}`, {method: 'DELETE'});
showToast('删除成功', 'success');
loadAttendance();
loadAttendanceStats();
} catch (err) {
showToast('删除失败: ' + err.message, 'error');
}
}
2026-03-27 10:19:34 +00:00
// ==================== BLUETOOTH ====================
2026-03-31 10:11:33 +00:00
async function loadBluetoothStats() {
try {
const stats = await apiCall(`${API_BASE}/bluetooth/stats`);
document.getElementById('btStatTotal').textContent = stats.total || 0;
document.getElementById('btStatPunch').textContent = stats.by_type?.punch || 0;
document.getElementById('btStatLocation').textContent = stats.by_type?.location || 0;
document.getElementById('btStatBeacons').textContent = stats.top_beacons?.length || 0;
} catch (err) {
console.error('Failed to load bluetooth stats:', err);
}
}
2026-03-27 10:19:34 +00:00
async function loadBluetooth(page) {
if (page) pageState.bluetooth.page = page;
const p = pageState.bluetooth.page;
const ps = pageState.bluetooth.pageSize;
const deviceId = document.getElementById('btDeviceFilter').value;
const recordType = document.getElementById('btTypeFilter').value;
const startTime = document.getElementById('btStartDate').value;
const endTime = document.getElementById('btEndDate').value;
let url = `${API_BASE}/bluetooth?page=${p}&page_size=${ps}`;
if (deviceId) url += `&device_id=${deviceId}`;
if (recordType) url += `&record_type=${recordType}`;
if (startTime) url += `&start_time=${startTime}T00:00:00`;
if (endTime) url += `&end_time=${endTime}T23:59:59`;
showLoading('bluetoothLoading');
try {
const data = await apiCall(url);
const items = data.items || [];
const tbody = document.getElementById('bluetoothTableBody');
if (items.length === 0) {
2026-03-31 02:03:21 +00:00
tbody.innerHTML = '< tr > < td colspan = "9" class = "text-center text-gray-500 py-8" > 没有蓝牙记录< / td > < / tr > ';
2026-03-27 10:19:34 +00:00
} else {
tbody.innerHTML = items.map(b => {
const typeIcon = b.record_type === 'punch' ? '< i class = "fas fa-fingerprint text-purple-400" > < / i > ' : '< i class = "fas fa-map-marker-alt text-cyan-400" > < / i > ';
const typeName = b.record_type === 'punch' ? '打卡' : '定位';
const mac = b.beacon_mac || '-';
const uuid = b.beacon_uuid ? `< span title = "${escapeHtml(b.beacon_uuid)}" > ${escapeHtml(b.beacon_uuid?.substring(0, 8) || '')}...< / span > ` : '-';
const majorMinor = b.beacon_major != null ? `${b.beacon_major} / ${b.beacon_minor}` : '';
const uuidLine = uuid !== '-' ? `${uuid}< br > < span class = "text-gray-500" > ${majorMinor}< / span > ` : '-';
const rssiBar = b.rssi != null ? `< span class = "${b.rssi > -60 ? 'text-green-400' : b.rssi > -80 ? 'text-yellow-400' : 'text-red-400'}" > ${b.rssi} dBm< / span > ` : '-';
const battStr = b.beacon_battery != null ? `${Number(b.beacon_battery).toFixed(2)}${b.beacon_battery_unit || 'V'}` : '-';
const attStr = b.attendance_type ? `< span class = "${b.attendance_type === 'clock_in' ? 'text-green-400' : 'text-blue-400'}" > ${attendanceTypeName(b.attendance_type)}< / span > ` : '-';
return `< tr >
2026-03-31 02:03:21 +00:00
< td onclick = "event.stopPropagation()" > < input type = "checkbox" class = "bt-sel-cb" value = "${b.id}" onchange = "updateSelCount('bt-sel-cb','btSelCount','btnBatchDeleteBt')" > < / td >
2026-03-31 09:41:09 +00:00
< td class = "font-mono text-xs" > ${escapeHtml(_imei(b.device_id))}< / td >
2026-03-27 10:19:34 +00:00
< td > ${typeIcon} < span class = "font-semibold" > ${typeName}< / span > < / td >
< td class = "font-mono text-xs" > ${escapeHtml(mac)}< / td >
< td class = "text-xs" > ${uuidLine}< / td >
< td class = "text-xs" > ${rssiBar}< / td >
< td class = "text-xs" > ${battStr}< / td >
< td class = "text-xs" > ${attStr}< / td >
< td class = "text-xs text-gray-400" > ${formatTime(b.recorded_at)}< / td >
< / tr > `;
}).join('');
}
2026-03-31 02:03:21 +00:00
document.getElementById('btSelectAll').checked = false;
2026-03-27 10:19:34 +00:00
renderPagination('bluetoothPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadBluetooth');
} catch (err) {
showToast('加载蓝牙记录失败: ' + err.message, 'error');
2026-03-31 02:03:21 +00:00
document.getElementById('bluetoothTableBody').innerHTML = '< tr > < td colspan = "9" class = "text-center text-red-400 py-8" > 加载失败< / td > < / tr > ';
2026-03-27 10:19:34 +00:00
} finally {
hideLoading('bluetoothLoading');
}
}
// ==================== BEACONS ====================
async function loadBeacons(page) {
if (page) pageState.beacons.page = page;
const p = pageState.beacons.page;
const ps = pageState.beacons.pageSize;
const search = document.getElementById('beaconSearch').value.trim();
const status = document.getElementById('beaconStatusFilter').value;
let url = `${API_BASE}/beacons?page=${p}&page_size=${ps}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
if (status) url += `&status=${status}`;
showLoading('beaconsLoading');
try {
const data = await apiCall(url);
const items = data.items || [];
const tbody = document.getElementById('beaconsTableBody');
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(b => {
const uuid = b.beacon_uuid ? `< span title = "${escapeHtml(b.beacon_uuid)}" > ${escapeHtml(b.beacon_uuid.substring(0, 8))}...< / span > ` : '-';
const majorMinor = b.beacon_major != null ? `${b.beacon_major} / ${b.beacon_minor}` : '';
const uuidLine = b.beacon_uuid ? `${uuid}< br > < span class = "text-gray-500" > ${majorMinor}< / span > ` : (majorMinor || '-');
const floorArea = [b.floor, b.area].filter(Boolean).join(' / ') || '-';
const coords = (b.latitude & & b.longitude) ? `< span class = "text-green-400" > ${Number(b.latitude).toFixed(5)}, ${Number(b.longitude).toFixed(5)}< / span > ` : '< span class = "text-gray-500" > 未设置< / span > ';
const statusBadge = b.status === 'active'
? '< span style = "padding:2px 8px;border-radius:9999px;background:#065f4620;color:#34d399;font-size:12px" > 启用< / span > '
: '< span style = "padding:2px 8px;border-radius:9999px;background:#7f1d1d20;color:#f87171;font-size:12px" > 停用< / span > ';
return `< tr >
< td class = "font-mono text-xs" > ${escapeHtml(b.beacon_mac)}< / td >
< td class = "font-semibold" > ${escapeHtml(b.name)}< / td >
< td class = "text-xs" > ${uuidLine}< / td >
< td class = "text-xs" > ${escapeHtml(floorArea)}< / td >
< td class = "text-xs" > ${coords}< / td >
< td > ${statusBadge}< / td >
< td class = "text-xs text-gray-400" > ${formatTime(b.updated_at || b.created_at)}< / td >
< td >
< button class = "btn btn-secondary" style = "padding:4px 10px;font-size:12px" onclick = "showEditBeaconModal(${b.id})" > < i class = "fas fa-edit" > < / i > < / button >
< button class = "btn btn-secondary" style = "padding:4px 10px;font-size:12px;color:#f87171" onclick = "confirmDeleteBeacon(${b.id}, '${escapeHtml(b.name)}')" > < i class = "fas fa-trash" > < / i > < / button >
< / td >
< / tr > `;
}).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 > ';
} finally {
hideLoading('beaconsLoading');
}
}
2026-03-27 13:04:11 +00:00
// ==================== DATA LOG ====================
let logPageState = { page: 1, pageSize: 30 };
let logDeviceMap = {}; // {device_id: {imei, name}}
async function loadDataLogStats() {
// Load counts for each type (today)
const today = new Date().toISOString().split('T')[0];
const base = `&start_time=${today}T00:00:00&end_time=${today}T23:59:59`;
try {
const [loc, alarm, hb, att, bt] = await Promise.all([
apiCall(`${API_BASE}/locations?page=1& page_size=1${base}`),
apiCall(`${API_BASE}/alarms?page=1& page_size=1`),
apiCall(`${API_BASE}/heartbeats?page=1& page_size=1`),
apiCall(`${API_BASE}/attendance?page=1& page_size=1`),
apiCall(`${API_BASE}/bluetooth?page=1& page_size=1`),
]);
document.getElementById('logCountLoc').textContent = loc.total || 0;
document.getElementById('logCountAlarm').textContent = alarm.total || 0;
document.getElementById('logCountHb').textContent = hb.total || 0;
document.getElementById('logCountAtt').textContent = att.total || 0;
document.getElementById('logCountBt').textContent = bt.total || 0;
} catch (_) {}
// Populate device filter + build IMEI map
const sel = document.getElementById('logDeviceFilter');
if (sel.options.length < = 1) {
try {
const devs = await apiCall(`${API_BASE}/devices?page=1&page_size=100`);
(devs.items || []).forEach(d => {
logDeviceMap[d.id] = { imei: d.imei, name: d.name || d.imei };
const opt = document.createElement('option');
opt.value = d.id;
opt.textContent = `${d.name || d.imei} (ID:${d.id})`;
sel.appendChild(opt);
});
} catch (_) {}
}
}
async function loadDataLog(page) {
if (page) logPageState.page = page;
const p = logPageState.page;
const ps = logPageState.pageSize;
const deviceId = document.getElementById('logDeviceFilter').value;
const logType = document.getElementById('logTypeFilter').value;
const startDate = document.getElementById('logStartDate').value;
const endDate = document.getElementById('logEndDate').value;
// Determine which API endpoint to call based on type
const type = logType || 'location'; // default to location
const endpointMap = {
location: 'locations', alarm: 'alarms', heartbeat: 'heartbeats',
attendance: 'attendance', bluetooth: 'bluetooth',
};
const endpoint = endpointMap[type] || 'locations';
let url = `${API_BASE}/${endpoint}?page=${p}&page_size=${ps}`;
if (deviceId) url += `&device_id=${deviceId}`;
if (startDate) url += `&start_time=${startDate}T00:00:00`;
if (endDate) url += `&end_time=${endDate}T23:59:59`;
showLoading('datalogLoading');
try {
const data = await apiCall(url);
const items = data.items || [];
const tbody = document.getElementById('datalogTableBody');
if (items.length === 0) {
2026-03-31 02:03:21 +00:00
tbody.innerHTML = '< tr > < td colspan = "9" class = "text-center text-gray-500 py-8" > 暂无记录< / td > < / tr > ';
2026-03-27 13:04:11 +00:00
} else {
tbody.innerHTML = items.map(r => {
const detail = _logDetail(type, r);
const lat = r.latitude != null ? Number(r.latitude).toFixed(6) : '-';
const lng = r.longitude != null ? Number(r.longitude).toFixed(6) : '-';
const coord = r.latitude != null ? `${lat}, ${lng}` : '-';
const addr = r.address || '-';
const time = r.recorded_at || r.alarm_time || r.heartbeat_time || r.created_at;
const typeBadge = _logTypeBadge(type);
return `< tr >
2026-03-31 02:03:21 +00:00
< td onclick = "event.stopPropagation()" > < input type = "checkbox" class = "log-sel-cb" value = "${r.id}" onchange = "updateSelCount('log-sel-cb','logSelCount','btnBatchDeleteLog')" > < / td >
2026-03-27 13:04:11 +00:00
< td class = "font-mono text-xs" > ${r.id}< / td >
< td > ${typeBadge}< / td >
2026-03-31 09:41:09 +00:00
< td class = "font-mono text-xs" > ${escapeHtml(r.imei || _imei(r.device_id))}< / td >
2026-03-27 13:04:11 +00:00
< 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 >
< td class = "text-xs text-gray-400" > ${formatTime(time)}< / td >
< / tr > `;
}).join('');
}
2026-03-31 02:03:21 +00:00
document.getElementById('logSelectAll').checked = false;
2026-03-27 13:04:11 +00:00
renderPagination('datalogPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadDataLog');
} catch (err) {
2026-03-31 02:03:21 +00:00
document.getElementById('datalogTableBody').innerHTML = '< tr > < td colspan = "9" class = "text-center text-red-400 py-8" > 加载失败: ' + escapeHtml(err.message) + '< / td > < / tr > ';
2026-03-27 13:04:11 +00:00
} finally {
hideLoading('datalogLoading');
}
}
function _logTypeBadge(type) {
const map = {
location: { label: '位置', bg: '#1e3a5f', fg: '#93c5fd' },
alarm: { label: '告警', bg: '#7f1d1d', fg: '#fca5a5' },
heartbeat: { label: '心跳', bg: '#065f46', fg: '#6ee7b7' },
attendance: { label: '考勤', bg: '#78350f', fg: '#fde68a' },
bluetooth: { label: '蓝牙', bg: '#3b1f5f', fg: '#c4b5fd' },
};
const m = map[type] || { label: type, bg: '#374151', fg: '#9ca3af' };
return `< span class = "badge" style = "background:${m.bg};color:${m.fg}" > ${m.label}< / span > `;
}
function _logDetail(type, r) {
switch (type) {
case 'location':
return `${_locTypeLabel(r.location_type)} | 速度:${r.speed ?? '-'} | 卫星:${r.gps_satellites ?? '-'}`;
case 'alarm':
return `${r.alarm_type || '-'} | 来源:${r.alarm_source || '-'} | ${r.acknowledged ? '已确认' : '未确认'}`;
case 'heartbeat':
return `电量:${r.battery_level ?? '-'}% | 信号:${r.gsm_signal ?? '-'} | 步数:${r.step_count ?? '-'}`;
case 'attendance':
return `${r.attendance_type === 'clock_in' ? '签到' : r.attendance_type === 'clock_out' ? '签退' : r.attendance_type || '-'} | ${_locTypeLabel(r.location_type)}`;
case 'bluetooth':
return `${r.record_type === 'punch' ? '打卡' : '定位'} | MAC:${r.beacon_mac || '-'} | RSSI:${r.rssi ?? '-'}`;
default:
return '-';
}
}
// ==================== FENCES ====================
let fenceMap = null;
let fenceMouseTool = null;
let fenceOverlays = {}; // {fenceId: overlay} rendered on map
let fenceSearchMarker = null;
function initFenceMap() {
if (fenceMap) return;
setTimeout(() => {
const [mLat, mLng] = toMapCoord(30.605, 103.936);
fenceMap = new AMap.Map('fenceMap', {
viewMode: '3D',
pitch: 40,
rotation: -15,
rotateEnable: true,
pitchEnable: true,
zoom: 12,
zooms: [2, 20],
center: [mLng, mLat],
mapStyle: 'amap://styles/normal',
});
}, 100);
}
function clearFenceOverlays() {
Object.values(fenceOverlays).forEach(o => { if (o) fenceMap.remove(o); });
fenceOverlays = {};
}
function _createFenceOverlay(f) {
const color = f.color || '#3b82f6';
const opts = { strokeColor: color, strokeWeight: 2, fillColor: f.fill_color || color, fillOpacity: f.fill_opacity || 0.2 };
if (f.fence_type === 'circle' & & f.center_lat != null & & f.center_lng != null & & f.radius) {
const [mLat, mLng] = toMapCoord(f.center_lat, f.center_lng);
return new AMap.Circle({ center: [mLng, mLat], radius: f.radius, ...opts });
} else if ((f.fence_type === 'polygon' || f.fence_type === 'rectangle') & & f.points) {
let pts; try { pts = JSON.parse(f.points); } catch (_) { return null; }
const path = pts.map(p => { const [mLat, mLng] = toMapCoord(p[1], p[0]); return [mLng, mLat]; });
return new AMap.Polygon({ path, ...opts });
}
return null;
}
function renderFencesOnMap(fences) {
if (!fenceMap) return;
clearFenceOverlays();
fences.forEach(f => {
const overlay = _createFenceOverlay(f);
if (overlay) {
overlay.setMap(fenceMap);
const typeLabel = f.fence_type === 'circle' ? '圆形' : f.fence_type === 'polygon' ? '多边形' : '矩形';
const detail = f.fence_type === 'circle' ? `半径: ${f.radius}m` : '';
const infoWindow = new AMap.InfoWindow({
content: `< div style = "color:#333;font-size:13px;line-height:1.6" > < b style = "color:#111" > ${escapeHtml(f.name)}< / b >
< span style = "padding:1px 6px;border-radius:4px;font-size:11px;background:${f.color};color:#fff;margin-left:4px" > ${typeLabel}< / span > < br >
${detail ? detail + '< br > ' : ''}${f.description ? escapeHtml(f.description) : ''}< / div > `,
offset: new AMap.Pixel(0, -5),
});
overlay.on('click', (e) => infoWindow.open(fenceMap, e.lnglat));
fenceOverlays[f.id] = overlay;
// Respect visibility: hide inactive
if (!f.is_active) overlay.hide();
}
});
const visible = Object.values(fenceOverlays).filter(o => o);
if (visible.length) fenceMap.setFitView(visible, false, [50, 50, 50, 50]);
}
function toggleFenceVisible(fenceId, show) {
const overlay = fenceOverlays[fenceId];
if (overlay) { show ? overlay.show() : overlay.hide(); }
}
function toggleAllFencesVisible(show) {
Object.values(fenceOverlays).forEach(o => { if (o) show ? o.show() : o.hide(); });
// Update panel checkbox UI
document.querySelectorAll('.fence-vis-cb').forEach(cb => cb.checked = show);
}
function focusFenceOnMap(fenceId) {
const overlay = fenceOverlays[fenceId];
if (!overlay || !fenceMap) return;
overlay.show();
fenceMap.setFitView([overlay], false, [80, 80, 80, 80]);
selectedPanelFenceId = fenceId;
// Highlight in panel
document.querySelectorAll('#fencePanelList .panel-item').forEach(el => el.classList.toggle('active', el.dataset.fenceId == fenceId));
}
function renderFencePanel(fences) {
panelFences = fences;
const container = _initPanelRender(
PANEL_IDS.fences, fences, 'is_active', true, '暂无围栏',
(a, t) => `${a}/${t}`, (a, t) => `共 ${t} 个围栏,${a} 个启用`
);
if (!container) return;
container.innerHTML = fences.map(f => {
const isActive = f.id == selectedPanelFenceId;
const typeLabel = f.fence_type === 'circle' ? '圆形' : f.fence_type === 'polygon' ? '多边形' : '矩形';
return `< div class = "panel-item ${isActive ? 'active' : ''}" data-fence-id = "${f.id}" data-search-text = "${(f.name||'').toLowerCase()} ${(f.description||'').toLowerCase()}" onclick = "focusFenceOnMap(${f.id})" >
< div class = "panel-item-actions" style = "display:flex;gap:4px;align-items:center" >
< input type = "checkbox" class = "fence-vis-cb" $ { f . is_active ? ' checked ' : ' ' } onclick = "event.stopPropagation();toggleFenceVisible(${f.id},this.checked)" title = "显示/隐藏" style = "cursor:pointer" >
< button class = "panel-action-btn" onclick = "event.stopPropagation();showEditFenceModal(${f.id})" title = "编辑" > < i class = "fas fa-edit" > < / i > < / button >
< button class = "panel-action-btn" style = "color:#10b981" onclick = "event.stopPropagation();showFenceBindingsModal(${f.id},'${escapeHtml(f.name)}')" title = "绑定设备" > < i class = "fas fa-link" > < / i > < / button >
< / div >
< div class = "panel-item-header" >
< span style = "display:inline-block;width:10px;height:10px;border-radius:2px;background:${f.color};margin-right:4px" > < / span >
< span class = "panel-item-name" > ${escapeHtml(f.name)}< / span >
< / div >
< div class = "panel-item-sub" > ${typeLabel}${f.fence_type === 'circle' & & f.radius ? ' · ' + f.radius + 'm' : ''}< / div >
< div class = "panel-item-meta" >
< span > ${f.description ? escapeHtml(f.description.substring(0, 20)) : '-'}< / span >
< span style = "color:${f.is_active ? '#34d399' : '#6b7280'}" > ${f.is_active ? '启用' : '停用'}< / span >
< / div >
< / div > `;
}).join('');
}
async function searchFenceMapLocation() {
const query = document.getElementById('fenceMapSearchInput').value.trim();
if (!query) return;
try {
const res = await apiCall(`${API_BASE}/geocode/search?keyword=${encodeURIComponent(query)}`);
if (!res.results || !res.results.length) { showToast('未找到该地点', 'info'); return; }
const r = res.results[0];
const [lng, lat] = r.location.split(',').map(Number);
if (fenceSearchMarker) fenceMap.remove(fenceSearchMarker);
fenceSearchMarker = new AMap.Marker({ position: [lng, lat] });
fenceSearchMarker.setMap(fenceMap);
const iw = new AMap.InfoWindow({
content: `< div style = "color:#333;font-size:13px" > < b > ${escapeHtml(r.name)}< / b > < br > ${escapeHtml(r.address || '')}< / div > `,
offset: new AMap.Pixel(0, -30),
});
iw.open(fenceMap, [lng, lat]);
fenceMap.setZoomAndCenter(16, [lng, lat]);
} catch (err) {
showToast('搜索失败: ' + err.message, 'error');
}
}
2026-03-31 10:11:33 +00:00
async function loadFenceStats() {
try {
const stats = await apiCall(`${API_BASE}/fences/stats`);
document.getElementById('fenceStatTotal').textContent = stats.total || 0;
document.getElementById('fenceStatActive').textContent = stats.active || 0;
document.getElementById('fenceStatBindings').textContent = stats.total_bindings || 0;
document.getElementById('fenceStatEvents').textContent = stats.today_events || 0;
} catch (err) {
console.error('Failed to load fence stats:', err);
}
}
2026-03-27 13:04:11 +00:00
async function loadFences() {
showLoading('fencesLoading');
try {
const data = await apiCall(`${API_BASE}/fences?page=1&page_size=100`);
const items = data.items || [];
renderFencesOnMap(items);
renderFencePanel(items);
document.getElementById('fencesTableBody').innerHTML = items.length === 0
? '< tr > < td colspan = "8" class = "text-center text-gray-500 py-8" > 暂无围栏< / td > < / tr > '
: items.map(f => {
const typeLabel = f.fence_type === 'circle' ? '圆形' : f.fence_type === 'polygon' ? '多边形' : '矩形';
return `< tr style = "cursor:pointer" onclick = "focusFenceOnMap(${f.id})" >
< td > ${escapeHtml(f.name)}< / td >
< td > < span class = "badge" style = "background:${f.fence_type === 'circle' ? '#1e3a5f' : '#3b1f5f'};color:#93c5fd" > ${typeLabel}< / span > < / td >
< td > < span style = "display:inline-block;width:20px;height:20px;border-radius:4px;background:${f.color};vertical-align:middle;border:1px solid #555" > < / span > ${f.color}< / td >
< td > ${f.fence_type === 'circle' ? (f.radius ? f.radius + 'm' : '-') : (f.points ? JSON.parse(f.points).length + '个点' : '-')}< / td >
< td > ${f.description ? escapeHtml(f.description) : '-'}< / td >
< td > < span class = "badge ${f.is_active ? 'badge-online' : 'badge-offline'}" > ${f.is_active ? '启用' : '停用'}< / span > < / td >
< td > ${formatTime(f.updated_at || f.created_at)}< / td >
< td >
< button class = "btn btn-sm" onclick = "event.stopPropagation();showEditFenceModal(${f.id})" > < i class = "fas fa-edit" > < / i > < / button >
< button class = "btn btn-sm" style = "color:#10b981" onclick = "event.stopPropagation();showFenceBindingsModal(${f.id},'${escapeHtml(f.name)}')" title = "绑定设备" > < i class = "fas fa-link" > < / i > < / button >
< button class = "btn btn-sm" style = "color:#ef4444" onclick = "event.stopPropagation();deleteFence(${f.id},'${escapeHtml(f.name)}')" > < i class = "fas fa-trash" > < / i > < / button >
< / td >
< / tr > `;
}).join('');
} catch (err) {
document.getElementById('fencesTableBody').innerHTML = '< tr > < td colspan = "8" class = "text-center text-red-400 py-8" > 加载失败< / td > < / tr > ';
} finally {
hideLoading('fencesLoading');
}
}
function showAddFenceModal() {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-draw-polygon mr-2 text-blue-400" > < / i > 添加围栏< / h3 >
< div class = "form-group" > < label > 围栏名称 < span class = "text-red-400" > *< / span > < / label > < input type = "text" id = "addFenceName" placeholder = "如: 办公区域 A" > < / div >
< div class = "form-group" > < label > 围栏类型< / label >
< select id = "addFenceType" onchange = "toggleFenceTypeFields('add')" >
< option value = "polygon" selected > 多边形< / option >
< option value = "circle" > 圆形< / option >
< / select >
< / div >
< div class = "form-group" > < label > 描述< / label > < input type = "text" id = "addFenceDesc" placeholder = "可选描述" > < / div >
< div class = "grid grid-cols-2 gap-3" >
< div class = "form-group" > < label > 边框颜色< / label > < input type = "color" id = "addFenceColor" value = "#3b82f6" style = "height:38px;padding:2px" > < / div >
< div class = "form-group" > < label > 填充透明度< / label > < input type = "number" id = "addFenceOpacity" value = "0.2" min = "0" max = "1" step = "0.1" > < / div >
< / div >
< div class = "form-group" > < label > < i class = "fas fa-search" > < / i > 搜索地点定位< / label >
< div style = "display:flex;gap:6px" >
< input type = "text" id = "addFenceSearchInput" placeholder = "输入地点名称搜索..." style = "flex:1" onkeydown = "if(event.key==='Enter')searchFenceModalLocation('add')" >
< button class = "btn btn-secondary" onclick = "searchFenceModalLocation('add')" style = "white-space:nowrap" > < i class = "fas fa-search" > < / i > 搜索< / button >
< / div >
< div id = "addFenceSearchResults" style = "max-height:120px;overflow-y:auto;margin-top:4px" > < / div >
< / div >
< div style = "background:#1e293b;border:1px solid #334155;border-radius:8px;padding:10px 12px;margin-bottom:12px" >
< p class = "text-xs text-yellow-300 mb-1" style = "font-weight:600" > < i class = "fas fa-mouse-pointer" > < / i > 绘图操作说明< / p >
< p class = "text-xs text-gray-300" id = "addFenceDrawGuide" > < b > 多边形< / b > :鼠标< span style = "color:#60a5fa" > 左键点击< / span > 依次选择顶点,< span style = "color:#60a5fa" > 双击< / span > 或< span style = "color:#f87171" > 右键< / span > 结束绘制。重新绘制会替换上一个图形。< / p >
< / div >
< div id = "addFenceMapDiv" style = "height:350px;border-radius:8px;margin-bottom:12px;border:1px solid #374151;" > < / div >
< div id = "addFenceCircleFields" class = "grid grid-cols-3 gap-3" style = "display:none" >
< div class = "form-group" > < label > 纬度< / label > < input type = "text" id = "addFenceLat" placeholder = "自动" readonly > < / div >
< div class = "form-group" > < label > 经度< / label > < input type = "text" id = "addFenceLng" placeholder = "自动" readonly > < / div >
< div class = "form-group" > < label > 半径(米)< / label > < input type = "text" id = "addFenceRadius" placeholder = "自动" readonly > < / div >
< / div >
< div id = "addFencePolyFields" > < div class = "form-group" > < label > 顶点数据< / label > < textarea id = "addFencePoints" rows = "2" readonly placeholder = "在地图上绘制后自动生成" style = "font-size:12px" > < / textarea > < / div > < / div >
< div class = "flex gap-3 mt-4" >
< button class = "btn btn-primary flex-1" onclick = "saveFence()" > < i class = "fas fa-save" > < / i > 保存< / button >
< button class = "btn btn-secondary flex-1" onclick = "closeModal()" > 取消< / button >
< / div >
`);
setTimeout(() => initFenceDrawMap('addFenceMapDiv', 'add'), 200);
}
let _fenceDrawMap = null;
let _fenceDrawTool = null;
let _fenceDrawnOverlay = null;
let _fenceDrawMode = 'add'; // 'add' or 'edit'
function toggleFenceTypeFields(prefix) {
const type = document.getElementById(prefix + 'FenceType').value;
const circleEl = document.getElementById(prefix + 'FenceCircleFields');
const polyEl = document.getElementById(prefix + 'FencePolyFields');
if (circleEl) circleEl.style.display = type === 'circle' ? 'grid' : 'none';
if (polyEl) polyEl.style.display = type !== 'circle' ? 'block' : 'none';
// Update drawing guide text
const guideEl = document.getElementById(prefix + 'FenceDrawGuide');
if (guideEl) {
guideEl.innerHTML = type === 'circle'
? '< b > 圆形< / b > :鼠标< span style = "color:#60a5fa" > 左键按住拖拽< / span > 绘制圆形区域,松开完成。重新绘制会替换上一个图形。'
: '< b > 多边形< / b > :鼠标< span style = "color:#60a5fa" > 左键点击< / span > 依次选择顶点,< span style = "color:#60a5fa" > 双击< / span > 或< span style = "color:#f87171" > 右键< / span > 结束绘制。重新绘制会替换上一个图形。';
}
// Reset drawn overlay
if (_fenceDrawnOverlay) { _fenceDrawMap.remove(_fenceDrawnOverlay); _fenceDrawnOverlay = null; }
startFenceDraw(prefix);
}
function initFenceDrawMap(mapDivId, prefix) {
_fenceDrawMode = prefix;
const [mLat, mLng] = toMapCoord(30.605, 103.936);
_fenceDrawMap = new AMap.Map(mapDivId, {
zoom: 14, center: [mLng, mLat], mapStyle: 'amap://styles/normal',
});
startFenceDraw(prefix);
}
function startFenceDraw(prefix) {
if (_fenceDrawTool) _fenceDrawTool.close(false); // false = keep drawn overlays on map
_fenceDrawTool = new AMap.MouseTool(_fenceDrawMap);
const color = document.getElementById(prefix + 'FenceColor').value || '#3b82f6';
const opacity = parseFloat(document.getElementById(prefix + 'FenceOpacity').value) || 0.2;
const type = document.getElementById(prefix + 'FenceType').value;
const opts = { strokeColor: color, fillColor: color, fillOpacity: opacity, strokeWeight: 2 };
if (type === 'circle') {
_fenceDrawTool.circle(opts);
} else {
_fenceDrawTool.polygon(opts);
}
_fenceDrawTool.on('draw', (e) => {
// Remove previous drawn overlay, keep the new one
if (_fenceDrawnOverlay) _fenceDrawMap.remove(_fenceDrawnOverlay);
_fenceDrawnOverlay = e.obj;
if (type === 'circle') {
const center = e.obj.getCenter();
const [wLat, wLng] = gcj02ToWgs84(center.getLat(), center.getLng());
document.getElementById(prefix + 'FenceLat').value = wLat.toFixed(6);
document.getElementById(prefix + 'FenceLng').value = wLng.toFixed(6);
document.getElementById(prefix + 'FenceRadius').value = Math.round(e.obj.getRadius());
} else {
const path = e.obj.getPath();
const wgsPoints = path.map(p => {
const [wLat, wLng] = gcj02ToWgs84(p.getLat(), p.getLng());
return [parseFloat(wLng.toFixed(6)), parseFloat(wLat.toFixed(6))];
});
document.getElementById(prefix + 'FencePoints').value = JSON.stringify(wgsPoints);
}
// Allow redraw (new tool, keeps current overlay visible)
setTimeout(() => startFenceDraw(prefix), 100);
});
}
function destroyFenceDrawMap() {
if (_fenceDrawTool) { _fenceDrawTool.close(false); _fenceDrawTool = null; }
if (_fenceModalSearchMarker) { _fenceModalSearchMarker = null; }
if (_fenceDrawMap) { _fenceDrawMap.destroy(); _fenceDrawMap = null; }
_fenceDrawnOverlay = null;
}
let _fenceModalSearchMarker = null;
async function searchFenceModalLocation(prefix) {
const input = document.getElementById(prefix + 'FenceSearchInput');
const resultsDiv = document.getElementById(prefix + 'FenceSearchResults');
if (!input || !resultsDiv) return;
const query = input.value.trim();
if (!query) { resultsDiv.innerHTML = ''; return; }
try {
const res = await apiCall(`${API_BASE}/geocode/search?keyword=${encodeURIComponent(query)}`);
if (!res.results || !res.results.length) { resultsDiv.innerHTML = '< div style = "padding:6px;color:#9ca3af;font-size:12px" > 未找到结果< / div > '; return; }
resultsDiv.innerHTML = res.results.map((r, i) => {
const [lng, lat] = r.location.split(',');
return `< div class = "beacon-search-item" style = "padding:6px 8px;cursor:pointer;border-bottom:1px solid #374151;font-size:12px;color:#d1d5db" onclick = "selectFenceModalSearchResult('${prefix}', ${lng}, ${lat}, '${escapeHtml(r.name)}')" >
< i class = "fas fa-map-marker-alt text-blue-400 mr-1" > < / i > < b > ${escapeHtml(r.name)}< / b >
< span style = "color:#6b7280;margin-left:6px" > ${escapeHtml(r.address || '')}< / span >
< / div > `;
}).join('');
} catch (err) {
resultsDiv.innerHTML = `< div style = "padding:6px;color:#f87171;font-size:12px" > 搜索失败: ${err.message}< / div > `;
}
}
function selectFenceModalSearchResult(prefix, lng, lat, name) {
if (!_fenceDrawMap) return;
// Clear search results
const resultsDiv = document.getElementById(prefix + 'FenceSearchResults');
if (resultsDiv) resultsDiv.innerHTML = '';
// Move map to location
if (_fenceModalSearchMarker) _fenceDrawMap.remove(_fenceModalSearchMarker);
_fenceModalSearchMarker = new AMap.Marker({ position: [lng, lat] });
_fenceModalSearchMarker.setMap(_fenceDrawMap);
const iw = new AMap.InfoWindow({
content: `< div style = "color:#333;font-size:13px" > < b > ${name}< / b > < / div > `,
offset: new AMap.Pixel(0, -30),
});
iw.open(_fenceDrawMap, [lng, lat]);
_fenceDrawMap.setZoomAndCenter(16, [lng, lat]);
}
async function saveFence() {
const prefix = _fenceDrawMode;
const name = document.getElementById(prefix + 'FenceName').value.trim();
if (!name) { showToast('请输入围栏名称', 'error'); return; }
const fenceType = document.getElementById(prefix + 'FenceType').value;
const color = document.getElementById(prefix + 'FenceColor').value;
const opacity = parseFloat(document.getElementById(prefix + 'FenceOpacity').value) || 0.2;
const desc = document.getElementById(prefix + 'FenceDesc').value.trim();
const body = { name, fence_type: fenceType, color, fill_color: color, fill_opacity: opacity, description: desc || null, is_active: true };
if (fenceType === 'circle') {
const lat = parseFloat(document.getElementById(prefix + 'FenceLat').value);
const lng = parseFloat(document.getElementById(prefix + 'FenceLng').value);
const radius = parseFloat(document.getElementById(prefix + 'FenceRadius').value);
if (isNaN(lat) || isNaN(lng) || isNaN(radius)) { showToast('请在地图上画一个圆形围栏', 'error'); return; }
body.center_lat = lat; body.center_lng = lng; body.radius = radius;
} else {
const points = document.getElementById(prefix + 'FencePoints').value;
if (!points) { showToast('请在地图上画一个多边形围栏', 'error'); return; }
body.points = points;
// Calculate centroid for display
try {
const pts = JSON.parse(points);
body.center_lng = pts.reduce((s, p) => s + p[0], 0) / pts.length;
body.center_lat = pts.reduce((s, p) => s + p[1], 0) / pts.length;
} catch (_) {}
}
try {
if (prefix === 'edit') {
const id = document.getElementById('editFenceId').value;
await apiCall(`${API_BASE}/fences/${id}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
showToast('围栏已更新');
} else {
await apiCall(`${API_BASE}/fences`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
showToast('围栏已创建');
}
destroyFenceDrawMap();
closeModal();
loadFences();
} catch (err) {
showToast('保存失败: ' + err.message, 'error');
}
}
async function showEditFenceModal(id) {
try {
const f = await apiCall(`${API_BASE}/fences/${id}`);
const isCircle = f.fence_type === 'circle';
const guideText = isCircle
? '< b > 圆形< / b > :鼠标< span style = "color:#60a5fa" > 左键按住拖拽< / span > 绘制圆形区域,松开完成。重新绘制会替换上一个图形。'
: '< b > 多边形< / b > :鼠标< span style = "color:#60a5fa" > 左键点击< / span > 依次选择顶点,< span style = "color:#60a5fa" > 双击< / span > 或< span style = "color:#f87171" > 右键< / span > 结束绘制。重新绘制会替换上一个图形。';
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-edit mr-2 text-blue-400" > < / i > 编辑围栏< / h3 >
< input type = "hidden" id = "editFenceId" value = "${f.id}" >
< div class = "form-group" > < label > 围栏名称 < span class = "text-red-400" > *< / span > < / label > < input type = "text" id = "editFenceName" value = "${escapeHtml(f.name)}" > < / div >
< div class = "form-group" > < label > 围栏类型< / label >
< select id = "editFenceType" onchange = "toggleFenceTypeFields('edit')" >
< option value = "polygon" $ { f . fence_type = == ' polygon ' ? ' selected ' : ' ' } > 多边形< / option >
< option value = "circle" $ { isCircle ? ' selected ' : ' ' } > 圆形< / option >
< / select >
< / div >
< div class = "form-group" > < label > 描述< / label > < input type = "text" id = "editFenceDesc" value = "${f.description ? escapeHtml(f.description) : ''}" > < / div >
< div class = "grid grid-cols-2 gap-3" >
< div class = "form-group" > < label > 边框颜色< / label > < input type = "color" id = "editFenceColor" value = "${f.color || '#3b82f6'}" style = "height:38px;padding:2px" > < / div >
< div class = "form-group" > < label > 填充透明度< / label > < input type = "number" id = "editFenceOpacity" value = "${f.fill_opacity || 0.2}" min = "0" max = "1" step = "0.1" > < / div >
< / div >
< div class = "form-group" > < label > < i class = "fas fa-search" > < / i > 搜索地点定位< / label >
< div style = "display:flex;gap:6px" >
< input type = "text" id = "editFenceSearchInput" placeholder = "输入地点名称搜索..." style = "flex:1" onkeydown = "if(event.key==='Enter')searchFenceModalLocation('edit')" >
< button class = "btn btn-secondary" onclick = "searchFenceModalLocation('edit')" style = "white-space:nowrap" > < i class = "fas fa-search" > < / i > 搜索< / button >
< / div >
< div id = "editFenceSearchResults" style = "max-height:120px;overflow-y:auto;margin-top:4px" > < / div >
< / div >
< div style = "background:#1e293b;border:1px solid #334155;border-radius:8px;padding:10px 12px;margin-bottom:12px" >
< p class = "text-xs text-yellow-300 mb-1" style = "font-weight:600" > < i class = "fas fa-mouse-pointer" > < / i > 绘图操作说明< / p >
< p class = "text-xs text-gray-300" id = "editFenceDrawGuide" > ${guideText}< / p >
< / div >
< div id = "editFenceMapDiv" style = "height:350px;border-radius:8px;margin-bottom:12px;border:1px solid #374151;" > < / div >
< div id = "editFenceCircleFields" class = "grid grid-cols-3 gap-3" $ { ! isCircle ? ' style = "display:none" ' : ' ' } >
< div class = "form-group" > < label > 纬度< / label > < input type = "text" id = "editFenceLat" value = "${f.center_lat || ''}" readonly > < / div >
< div class = "form-group" > < label > 经度< / label > < input type = "text" id = "editFenceLng" value = "${f.center_lng || ''}" readonly > < / div >
< div class = "form-group" > < label > 半径(米)< / label > < input type = "text" id = "editFenceRadius" value = "${f.radius || ''}" readonly > < / div >
< / div >
< div id = "editFencePolyFields" $ { isCircle ? ' style = "display:none" ' : ' ' } > < div class = "form-group" > < label > 顶点数据< / label > < textarea id = "editFencePoints" rows = "2" readonly style = "font-size:12px" > ${f.points || ''}< / textarea > < / div > < / div >
< div class = "flex gap-3 mt-4" >
< button class = "btn btn-primary flex-1" onclick = "saveFence()" > < i class = "fas fa-save" > < / i > 保存< / button >
< button class = "btn btn-secondary flex-1" onclick = "closeModal()" > 取消< / button >
< / div >
`);
setTimeout(() => {
initFenceDrawMap('editFenceMapDiv', 'edit');
// Render existing fence on edit map
if (f.fence_type === 'circle' & & f.center_lat & & f.center_lng & & f.radius) {
const [mLat, mLng] = toMapCoord(f.center_lat, f.center_lng);
const circle = new AMap.Circle({ center: [mLng, mLat], radius: f.radius, strokeColor: f.color, fillColor: f.color, fillOpacity: f.fill_opacity || 0.2, strokeWeight: 2 });
circle.setMap(_fenceDrawMap);
_fenceDrawMap.setFitView([circle]);
} else if (f.points) {
try {
const pts = JSON.parse(f.points).map(p => { const [mLat, mLng] = toMapCoord(p[1], p[0]); return [mLng, mLat]; });
const poly = new AMap.Polygon({ path: pts, strokeColor: f.color, fillColor: f.color, fillOpacity: f.fill_opacity || 0.2, strokeWeight: 2 });
poly.setMap(_fenceDrawMap);
_fenceDrawMap.setFitView([poly]);
} catch (_) {}
}
}, 200);
} catch (err) {
showToast('加载围栏失败: ' + err.message, 'error');
}
}
async function deleteFence(id, name) {
if (!confirm(`确定删除围栏 "${name}" ?`)) return;
try {
await apiCall(`${API_BASE}/fences/${id}`, { method: 'DELETE' });
showToast('围栏已删除');
loadFences();
} catch (err) {
showToast('删除失败: ' + err.message, 'error');
}
}
// ==================== FENCE DEVICE BINDING ====================
async function showFenceBindingsModal(fenceId, fenceName) {
let boundDevices = [];
try {
boundDevices = await apiCall(`${API_BASE}/fences/${fenceId}/devices`);
} catch (err) {
showToast('加载绑定设备失败: ' + err.message, 'error');
return;
}
const boundHtml = boundDevices.length === 0
? '< p class = "text-gray-500 text-center py-4" > 暂无绑定设备< / p > '
: `< table style = "width:100%;font-size:13px" > < thead > < tr >
< th > 设备< / th > < th > IMEI< / th > < th > 状态< / th > < th > 最后检测< / th > < th > 操作< / th >
< / tr > < / thead > < tbody > ${boundDevices.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 = "unbindDeviceFromFence(${fenceId},${d.device_id},'${escapeHtml(d.device_name||d.imei||" " ) } ' ) " > < i class = "fas fa-unlink" > < / i > < / button > < / td >
< / tr > `).join('')}< / tbody > < / table > `;
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-link mr-2 text-blue-400" > < / i > 围栏绑定设备 — ${escapeHtml(fenceName)}< / h3 >
< input type = "hidden" id = "bindFenceId" value = "${fenceId}" >
< div class = "mb-4" >
< label class = "block text-sm text-gray-400 mb-1" > 当前绑定设备< / label >
< div id = "fenceBindingsBody" style = "max-height:250px;overflow-y:auto;border:1px solid #374151;border-radius:8px;padding:8px" > ${boundHtml}< / div >
< / div >
< hr style = "border-color:#374151;margin:16px 0" >
< div class = "form-group" >
< label > < i class = "fas fa-plus-circle text-green-400" > < / i > 添加绑定设备< / label >
< div id = "fenceBindDeviceSelector" style = "max-height:200px;overflow-y:auto;border:1px solid #374151;border-radius:8px;padding:8px" >
< p class = "text-gray-500 text-center py-2" > 加载设备列表...< / p >
< / div >
< / div >
< div class = "flex gap-3 mt-4" >
< button class = "btn btn-primary flex-1" onclick = "confirmBindDevices()" > < i class = "fas fa-link" > < / i > 确认绑定选中设备< / button >
< button class = "btn btn-secondary flex-1" onclick = "closeModal()" > 关闭< / button >
< / div >
`);
// Load all devices for binding selector
try {
const devData = await apiCall(`${API_BASE}/devices?page=1&page_size=100`);
const allDevices = devData.items || [];
const boundIds = new Set(boundDevices.map(d => d.device_id));
const unbound = allDevices.filter(d => !boundIds.has(d.id));
const selector = document.getElementById('fenceBindDeviceSelector');
if (unbound.length === 0) {
selector.innerHTML = '< p class = "text-gray-500 text-center py-2" > 所有设备已绑定< / p > ';
} else {
selector.innerHTML = unbound.map(d => `
< label style = "display:flex;align-items:center;gap:8px;padding:4px 0;cursor:pointer" >
< input type = "checkbox" class = "fence-bind-cb" value = "${d.id}" >
< span > ${escapeHtml(d.name || '-')}< / span >
< span class = "font-mono text-xs text-gray-400" > ${escapeHtml(d.imei)}< / span >
< / label >
`).join('');
}
} catch (err) {
document.getElementById('fenceBindDeviceSelector').innerHTML = '< p class = "text-red-400 text-center py-2" > 加载设备失败< / p > ';
}
}
async function confirmBindDevices() {
const fenceId = document.getElementById('bindFenceId').value;
const checkboxes = document.querySelectorAll('.fence-bind-cb:checked');
if (checkboxes.length === 0) { showToast('请选择要绑定的设备', 'info'); return; }
const deviceIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
try {
const result = await apiCall(`${API_BASE}/fences/${fenceId}/devices`, {
method: 'POST',
body: JSON.stringify({ device_ids: deviceIds }),
});
showToast(`绑定成功: 新增${result.created || 0}个`);
closeModal();
} catch (err) {
showToast('绑定失败: ' + err.message, 'error');
}
}
async function unbindDeviceFromFence(fenceId, deviceId, deviceName) {
if (!confirm(`确定解绑设备 "${deviceName}" ?`)) return;
try {
await apiCall(`${API_BASE}/fences/${fenceId}/devices`, {
method: 'DELETE',
body: JSON.stringify({ device_ids: [deviceId] }),
});
showToast('已解绑');
// Refresh the modal
const fence = panelFences.find(f => f.id == fenceId);
showFenceBindingsModal(fenceId, fence ? fence.name : '');
} catch (err) {
showToast('解绑失败: ' + err.message, 'error');
}
}
2026-03-30 04:26:29 +00:00
// ---- Fence Tab switching & binding tab ----
function switchFenceTab(tab) {
document.getElementById('fenceTabList').classList.toggle('active', tab === 'list');
document.getElementById('fenceTabBindings').classList.toggle('active', tab === 'bindings');
2026-03-30 09:41:55 +00:00
document.getElementById('fenceTabContentList').style.display = tab === 'list' ? 'flex' : 'none';
document.getElementById('fenceTabContentBindings').style.display = tab === 'bindings' ? 'flex' : 'none';
if (tab === 'bindings') loadBindingMatrix();
2026-03-30 04:26:29 +00:00
}
2026-03-30 09:41:55 +00:00
let _bindMatrixState = {}; // { "fenceId-deviceId": true/false }
let _bindMatrixOriginal = {}; // original state for diff
let _bindFences = [];
let _bindDevices = [];
2026-03-30 04:26:29 +00:00
2026-03-30 09:41:55 +00:00
async function loadBindingMatrix() {
const thead = document.getElementById('fenceBindMatrixHead');
const tbody = document.getElementById('fenceBindMatrixBody');
2026-03-30 04:26:29 +00:00
try {
2026-03-30 09:41:55 +00:00
const [fenceData, deviceData] = await Promise.all([
apiCall(`${API_BASE}/fences?page=1& page_size=100`),
apiCall(`${API_BASE}/devices?page=1& page_size=100`),
]);
_bindFences = fenceData.items || [];
_bindDevices = deviceData.items || [];
if (!_bindFences.length) {
thead.innerHTML = '';
tbody.innerHTML = '< tr > < td class = "text-center text-gray-500 py-4" > 暂无围栏,请先创建< / td > < / tr > ';
return;
}
if (!_bindDevices.length) {
thead.innerHTML = '';
tbody.innerHTML = '< tr > < td class = "text-center text-gray-500 py-4" > 暂无设备< / td > < / tr > ';
return;
2026-03-30 04:26:29 +00:00
}
2026-03-30 09:41:55 +00:00
// Fetch bindings for all fences in parallel
const bindingsArr = await Promise.all(
_bindFences.map(f => apiCall(`${API_BASE}/fences/${f.id}/devices`).catch(() => []))
);
// Build state
_bindMatrixState = {};
_bindMatrixOriginal = {};
_bindFences.forEach((f, i) => {
const bound = bindingsArr[i] || [];
const boundIds = new Set(bound.map(b => b.device_id));
_bindDevices.forEach(d => {
const key = `${f.id}-${d.id}`;
const val = boundIds.has(d.id);
_bindMatrixState[key] = val;
_bindMatrixOriginal[key] = val;
});
2026-03-30 04:26:29 +00:00
});
2026-03-30 09:41:55 +00:00
// Render header
const fenceTypeName = t => ({circle:'圆',polygon:'多边',rectangle:'矩'}[t] || t);
thead.innerHTML = `< tr >
< th style = "position:sticky;left:0;background:#1f2937;z-index:2;min-width:120px" > 设备 \\ 围栏< / th >
${_bindFences.map(f => `< th style = "text-align:center;min-width:80px;font-size:11px;white-space:nowrap" title = "${escapeHtml(f.name)}" > ${escapeHtml(f.name)}< br > < span style = "color:#6b7280;font-weight:normal" > ${fenceTypeName(f.fence_type)}< / span > < / th > `).join('')}
< th style = "text-align:center;min-width:60px" > 全选< / th >
< / tr > `;
// Render body
tbody.innerHTML = _bindDevices.map(d => {
const label = d.name || d.imei || d.id;
const statusDot = d.status === 'online' ? '🟢' : '⚪';
return `< tr >
< td style = "position:sticky;left:0;background:#111827;z-index:1;white-space:nowrap;font-size:12px" > ${statusDot} ${escapeHtml(label)}< / td >
${_bindFences.map(f => {
const key = `${f.id}-${d.id}`;
const checked = _bindMatrixState[key] ? 'checked' : '';
return `< td style = "text-align:center" > < input type = "checkbox" $ { checked } onchange = "_bindMatrixState['${key}']=this.checked;updateBindSaveBtn()" > < / td > `;
}).join('')}
< td style = "text-align:center" > < input type = "checkbox" onchange = "toggleDeviceRow(${d.id},this.checked)" > < / td >
< / tr > `;
}).join('') + `< tr style = "border-top:1px solid #374151" >
< td style = "position:sticky;left:0;background:#111827;z-index:1;font-size:12px;color:#9ca3af" > 全选列< / td >
${_bindFences.map(f => `< td style = "text-align:center" > < input type = "checkbox" onchange = "toggleFenceCol(${f.id},this.checked)" > < / td > `).join('')}
< td style = "text-align:center" > < input type = "checkbox" onchange = "toggleAllBindings(this.checked)" > < / td >
< / tr > `;
updateBindSaveBtn();
2026-03-30 04:26:29 +00:00
} catch (err) {
2026-03-30 09:41:55 +00:00
thead.innerHTML = '';
tbody.innerHTML = `< tr > < td class = "text-center text-red-400 py-4" > 加载失败: ${escapeHtml(err.message)}< / td > < / tr > `;
2026-03-30 04:26:29 +00:00
}
}
2026-03-30 09:41:55 +00:00
function toggleDeviceRow(deviceId, checked) {
_bindFences.forEach(f => {
const key = `${f.id}-${deviceId}`;
_bindMatrixState[key] = checked;
});
refreshMatrixCheckboxes();
}
function toggleFenceCol(fenceId, checked) {
_bindDevices.forEach(d => {
const key = `${fenceId}-${d.id}`;
_bindMatrixState[key] = checked;
});
refreshMatrixCheckboxes();
}
function toggleAllBindings(checked) {
_bindFences.forEach(f => _bindDevices.forEach(d => {
_bindMatrixState[`${f.id}-${d.id}`] = checked;
}));
refreshMatrixCheckboxes();
}
function refreshMatrixCheckboxes() {
const tbody = document.getElementById('fenceBindMatrixBody');
const cbs = tbody.querySelectorAll('input[type="checkbox"]');
cbs.forEach(cb => {
const onchange = cb.getAttribute('onchange') || '';
const m = onchange.match(/_bindMatrixState\['(\d+-\d+)'\]/);
if (m) cb.checked = _bindMatrixState[m[1]] || false;
});
updateBindSaveBtn();
}
function updateBindSaveBtn() {
// Count changes
let changes = 0;
for (const key in _bindMatrixState) {
if (_bindMatrixState[key] !== _bindMatrixOriginal[key]) changes++;
}
const btn = document.getElementById('fenceBindSaveBtn');
if (btn) btn.innerHTML = changes > 0
? `< i class = "fas fa-save" > < / i > 保存更改 (${changes})`
: `< i class = "fas fa-save" > < / i > 保存更改`;
}
async function saveBindingMatrix() {
2026-04-01 07:06:37 +00:00
const btn = document.getElementById('fenceBindSaveBtn');
if (btn & & btn.disabled) return;
if (btn) { btn.disabled = true; btn.innerHTML = '< i class = "fas fa-spinner fa-spin" > < / i > 保存中...'; }
2026-03-30 09:41:55 +00:00
// Compute diffs per fence: which devices to bind, which to unbind
const toBind = {}; // fenceId -> [deviceIds]
const toUnbind = {}; // fenceId -> [deviceIds]
for (const key in _bindMatrixState) {
if (_bindMatrixState[key] === _bindMatrixOriginal[key]) continue;
const [fenceId, deviceId] = key.split('-').map(Number);
if (_bindMatrixState[key]) {
(toBind[fenceId] = toBind[fenceId] || []).push(deviceId);
} else {
(toUnbind[fenceId] = toUnbind[fenceId] || []).push(deviceId);
}
}
const ops = [];
for (const fid in toBind) {
ops.push(apiCall(`${API_BASE}/fences/${fid}/devices`, {
method: 'POST', body: JSON.stringify({ device_ids: toBind[fid] }),
}));
}
for (const fid in toUnbind) {
ops.push(apiCall(`${API_BASE}/fences/${fid}/devices`, {
method: 'DELETE', body: JSON.stringify({ device_ids: toUnbind[fid] }),
}));
}
2026-04-01 07:06:37 +00:00
if (!ops.length) {
showToast('没有更改', 'info');
if (btn) { btn.disabled = false; btn.innerHTML = '< i class = "fas fa-save" > < / i > 保存更改'; }
return;
}
2026-03-30 04:26:29 +00:00
try {
2026-03-30 09:41:55 +00:00
await Promise.all(ops);
showToast(`保存成功 (${ops.length} 项操作)`, 'success');
loadBindingMatrix(); // reload to sync state
2026-03-30 04:26:29 +00:00
} catch (err) {
2026-03-30 09:41:55 +00:00
showToast('保存失败: ' + err.message, 'error');
2026-04-01 07:06:37 +00:00
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '< i class = "fas fa-save" > < / i > 保存更改'; }
2026-03-30 04:26:29 +00:00
}
}
2026-03-27 10:19:34 +00:00
// ---- Beacon map picker ----
let _beaconPickerMap = null;
let _beaconPickerMarker = null;
let _beaconSearchTimeout = null;
function initBeaconPickerMap(mapDivId, latInputId, lonInputId, addrInputId, initLat, initLon) {
setTimeout(() => {
const defaultCenter = [30.605, 103.936];
const hasInit = initLat & & initLon;
const wgsCenter = hasInit ? [initLat, initLon] : defaultCenter;
const [mLat, mLng] = toMapCoord(wgsCenter[0], wgsCenter[1]);
const zoom = hasInit ? 16 : 12;
2026-03-27 13:04:11 +00:00
_beaconPickerMap = new AMap.Map(mapDivId, {
zoom: zoom,
center: [mLng, mLat],
mapStyle: 'amap://styles/normal',
});
2026-03-27 10:19:34 +00:00
if (hasInit) {
2026-03-27 13:04:11 +00:00
_beaconPickerMarker = new AMap.Marker({ position: [mLng, mLat] });
_beaconPickerMarker.setMap(_beaconPickerMap);
2026-03-27 10:19:34 +00:00
}
_beaconPickerMap.on('click', async (e) => {
2026-03-27 13:04:11 +00:00
const gcjLng = e.lnglat.getLng(), gcjLat = e.lnglat.getLat();
2026-03-27 10:19:34 +00:00
const [wgsLat, wgsLng] = gcj02ToWgs84(gcjLat, gcjLng);
document.getElementById(latInputId).value = wgsLat.toFixed(6);
document.getElementById(lonInputId).value = wgsLng.toFixed(6);
2026-03-27 13:04:11 +00:00
if (_beaconPickerMarker) _beaconPickerMarker.setPosition([gcjLng, gcjLat]);
else { _beaconPickerMarker = new AMap.Marker({ position: [gcjLng, gcjLat] }); _beaconPickerMarker.setMap(_beaconPickerMap); }
2026-03-27 10:19:34 +00:00
try {
const res = await apiCall(`${API_BASE}/geocode/reverse?lat=${wgsLat.toFixed(6)}&lon=${wgsLng.toFixed(6)}`);
if (res.address) document.getElementById(addrInputId).value = res.address;
} catch (_) {}
});
const syncMarker = () => {
const lat = parseFloat(document.getElementById(latInputId).value);
const lon = parseFloat(document.getElementById(lonInputId).value);
if (!isNaN(lat) & & !isNaN(lon) & & lat >= -90 & & lat < = 90 & & lon >= -180 & & lon < = 180) {
const [mLat, mLng] = toMapCoord(lat, lon);
2026-03-27 13:04:11 +00:00
if (_beaconPickerMarker) _beaconPickerMarker.setPosition([mLng, mLat]);
else { _beaconPickerMarker = new AMap.Marker({ position: [mLng, mLat] }); _beaconPickerMarker.setMap(_beaconPickerMap); }
_beaconPickerMap.setZoomAndCenter(16, [mLng, mLat]);
2026-03-27 10:19:34 +00:00
}
};
document.getElementById(latInputId).addEventListener('change', syncMarker);
document.getElementById(lonInputId).addEventListener('change', syncMarker);
}, 150);
}
async function searchBeaconLocation(query, resultsId, latInputId, lonInputId, addrInputId) {
if (_beaconSearchTimeout) clearTimeout(_beaconSearchTimeout);
const container = document.getElementById(resultsId);
if (!query || query.length < 2 ) { container . innerHTML = '' ; return ; }
_beaconSearchTimeout = setTimeout(async () => {
try {
const res = await apiCall(`${API_BASE}/geocode/search?keyword=${encodeURIComponent(query)}`);
if (!res.results || !res.results.length) {
container.innerHTML = '< div style = "color:#9ca3af;font-size:12px;padding:8px;" > 无搜索结果< / div > ';
return;
}
container.innerHTML = res.results.map(r => {
const [lng, lat] = r.location.split(',').map(Number);
return `< div class = "beacon-search-item" onclick = "selectBeaconSearchResult(${lat},${lng},'${latInputId}','${lonInputId}','${addrInputId}','${resultsId}')" style = "padding:6px 8px;cursor:pointer;border-bottom:1px solid #374151;font-size:13px;" >
< div style = "color:#93c5fd;" > ${escapeHtml(r.name)}< / div >
< div style = "color:#9ca3af;font-size:11px;" > ${escapeHtml(r.address || '')}< / div >
< / div > `;
}).join('');
} catch (_) { container.innerHTML = ''; }
}, 400);
}
function selectBeaconSearchResult(gcjLat, gcjLng, latInputId, lonInputId, addrInputId, resultsId) {
const [wgsLat, wgsLng] = gcj02ToWgs84(gcjLat, gcjLng);
document.getElementById(latInputId).value = wgsLat.toFixed(6);
document.getElementById(lonInputId).value = wgsLng.toFixed(6);
document.getElementById(resultsId).innerHTML = '';
if (_beaconPickerMap) {
2026-03-27 13:04:11 +00:00
if (_beaconPickerMarker) _beaconPickerMarker.setPosition([gcjLng, gcjLat]);
else { _beaconPickerMarker = new AMap.Marker({ position: [gcjLng, gcjLat] }); _beaconPickerMarker.setMap(_beaconPickerMap); }
_beaconPickerMap.setZoomAndCenter(16, [gcjLng, gcjLat]);
2026-03-27 10:19:34 +00:00
}
apiCall(`${API_BASE}/geocode/reverse?lat=${wgsLat.toFixed(6)}& lon=${wgsLng.toFixed(6)}`)
.then(res => { if (res.address) document.getElementById(addrInputId).value = res.address; })
.catch(() => {});
}
function destroyBeaconPickerMap() {
2026-03-27 13:04:11 +00:00
if (_beaconPickerMap) { _beaconPickerMap.destroy(); _beaconPickerMap = null; }
2026-03-27 10:19:34 +00:00
_beaconPickerMarker = null;
}
function showAddBeaconModal() {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-broadcast-tower mr-2 text-blue-400" > < / i > 添加蓝牙信标< / h3 >
< div class = "form-group" > < label > MAC 地址 < span class = "text-red-400" > *< / span > < / label > < input type = "text" id = "addBeaconMac" placeholder = "AA:BB:CC:DD:EE:FF" maxlength = "20" > < p class = "text-xs text-gray-500 mt-1" > 信标的蓝牙 MAC 地址,冒号分隔大写十六进制< / p > < / div >
< div class = "form-group" > < label > 名称 < span class = "text-red-400" > *< / span > < / label > < input type = "text" id = "addBeaconName" placeholder = "如: 前台大门 / A区3楼走廊" > < p class = "text-xs text-gray-500 mt-1" > 信标安装位置的描述性名称< / p > < / div >
< div class = "form-group" > < label > UUID< / label > < input type = "text" id = "addBeaconUuid" placeholder = "iBeacon UUID (可选)" maxlength = "36" > < p class = "text-xs text-gray-500 mt-1" > iBeacon 协议的 UUID 标识符,用于匹配设备上报的信标数据< / p > < / div >
< div class = "grid grid-cols-2 gap-3" >
< div class = "form-group" > < label > Major< / label > < input type = "number" id = "addBeaconMajor" placeholder = "0-65535" min = "0" max = "65535" > < p class = "text-xs text-gray-500 mt-1" > iBeacon Major 值,区分信标组< / p > < / div >
< div class = "form-group" > < label > Minor< / label > < input type = "number" id = "addBeaconMinor" placeholder = "0-65535" min = "0" max = "65535" > < p class = "text-xs text-gray-500 mt-1" > iBeacon Minor 值,区分组内信标< / p > < / div >
< / div >
< p style = "font-size:11px;color:#f59e0b;margin:8px 0 4px;" > < i class = "fas fa-map-marker-alt mr-1" > < / i > 位置信息 — 设置后蓝牙打卡/定位记录将自动关联此坐标< / p >
< div class = "form-group" > < label > < i class = "fas fa-search mr-1" > < / i > 搜索位置< / label >
< input type = "text" id = "addBeaconSearch" placeholder = "输入地址或地点名称搜索..." oninput = "searchBeaconLocation(this.value,'addBeaconSearchResults','addBeaconLat','addBeaconLon','addBeaconAddress')" >
< div id = "addBeaconSearchResults" style = "max-height:120px;overflow-y:auto;background:#111827;border-radius:6px;margin-top:4px;" > < / div >
< / div >
< div id = "addBeaconMapDiv" style = "height:220px;border-radius:8px;margin-bottom:8px;border:1px solid #374151;" > < / div >
< p style = "font-size:11px;color:#9ca3af;margin:-4px 0 12px;" > < i class = "fas fa-mouse-pointer mr-1" > < / i > 点击地图选择位置,或手动输入坐标< / p >
< div class = "grid grid-cols-2 gap-3" >
< div class = "form-group" > < label > 纬度< / label > < input type = "number" id = "addBeaconLat" step = "0.000001" placeholder = "如: 30.12345" > < / div >
< div class = "form-group" > < label > 经度< / label > < input type = "number" id = "addBeaconLon" step = "0.000001" placeholder = "如: 120.12345" > < / div >
< / div >
< div class = "form-group" > < label > 详细地址< / label > < input type = "text" id = "addBeaconAddress" placeholder = "点击地图或搜索自动填充" > < p class = "text-xs text-gray-500 mt-1" > 点击地图或搜索位置后自动填充< / p > < / div >
< div class = "flex gap-3 mt-6" >
< button class = "btn btn-primary flex-1" onclick = "submitAddBeacon()" > < i class = "fas fa-check" > < / i > 确认添加< / button >
< button class = "btn btn-secondary flex-1" onclick = "closeModal()" > < i class = "fas fa-times" > < / i > 取消< / button >
< / div >
`);
initBeaconPickerMap('addBeaconMapDiv', 'addBeaconLat', 'addBeaconLon', 'addBeaconAddress', null, null);
}
async function submitAddBeacon() {
const mac = document.getElementById('addBeaconMac').value.trim().toUpperCase();
const name = document.getElementById('addBeaconName').value.trim();
if (!mac) { showToast('请输入 MAC 地址', 'error'); return; }
if (!name) { showToast('请输入信标名称', 'error'); return; }
if (!/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/.test(mac)) { showToast('MAC 地址格式错误,应为 AA:BB:CC:DD:EE:FF', 'error'); return; }
const body = { beacon_mac: mac, name };
const uuid = document.getElementById('addBeaconUuid').value.trim();
const major = document.getElementById('addBeaconMajor').value;
const minor = document.getElementById('addBeaconMinor').value;
const lat = document.getElementById('addBeaconLat').value;
const lon = document.getElementById('addBeaconLon').value;
const address = document.getElementById('addBeaconAddress').value.trim();
if (uuid) body.beacon_uuid = uuid;
if (major !== '') body.beacon_major = parseInt(major);
if (minor !== '') body.beacon_minor = parseInt(minor);
if (lat !== '') body.latitude = parseFloat(lat);
if (lon !== '') body.longitude = parseFloat(lon);
if (address) body.address = address;
try {
await apiCall(`${API_BASE}/beacons`, { method: 'POST', body: JSON.stringify(body) });
showToast('信标添加成功');
closeModal();
loadBeacons();
} catch (err) {
showToast('添加失败: ' + err.message, 'error');
}
}
async function showEditBeaconModal(id) {
try {
const b = await apiCall(`${API_BASE}/beacons/${id}`);
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-edit mr-2 text-yellow-400" > < / i > 编辑信标< / h3 >
< div class = "form-group" > < label > MAC 地址< / label > < input type = "text" value = "${escapeHtml(b.beacon_mac)}" disabled style = "opacity:0.5" > < p class = "text-xs text-gray-500 mt-1" > MAC 地址不可修改,设备通过此地址匹配信标< / p > < / div >
< div class = "form-group" > < label > 名称 < span class = "text-red-400" > *< / span > < / label > < input type = "text" id = "editBeaconName" value = "${escapeHtml(b.name || '')}" > < p class = "text-xs text-gray-500 mt-1" > 信标安装位置的描述性名称< / p > < / div >
< div class = "form-group" > < label > UUID< / label > < input type = "text" id = "editBeaconUuid" value = "${escapeHtml(b.beacon_uuid || '')}" maxlength = "36" > < p class = "text-xs text-gray-500 mt-1" > iBeacon 协议的 UUID 标识符,用于匹配设备上报的信标数据< / p > < / div >
< div class = "grid grid-cols-2 gap-3" >
< div class = "form-group" > < label > Major< / label > < input type = "number" id = "editBeaconMajor" value = "${b.beacon_major != null ? b.beacon_major : ''}" min = "0" max = "65535" > < p class = "text-xs text-gray-500 mt-1" > iBeacon Major 值,区分信标组< / p > < / div >
< div class = "form-group" > < label > Minor< / label > < input type = "number" id = "editBeaconMinor" value = "${b.beacon_minor != null ? b.beacon_minor : ''}" min = "0" max = "65535" > < p class = "text-xs text-gray-500 mt-1" > iBeacon Minor 值,区分组内信标< / p > < / div >
< / div >
< p style = "font-size:11px;color:#f59e0b;margin:8px 0 4px;" > < i class = "fas fa-map-marker-alt mr-1" > < / i > 位置信息 — 设置后蓝牙打卡/定位记录将自动关联此坐标< / p >
< div class = "form-group" > < label > < i class = "fas fa-search mr-1" > < / i > 搜索位置< / label >
< input type = "text" id = "editBeaconSearch" placeholder = "输入地址或地点名称搜索..." oninput = "searchBeaconLocation(this.value,'editBeaconSearchResults','editBeaconLat','editBeaconLon','editBeaconAddress')" >
< div id = "editBeaconSearchResults" style = "max-height:120px;overflow-y:auto;background:#111827;border-radius:6px;margin-top:4px;" > < / div >
< / div >
< div id = "editBeaconMapDiv" style = "height:220px;border-radius:8px;margin-bottom:8px;border:1px solid #374151;" > < / div >
< p style = "font-size:11px;color:#9ca3af;margin:-4px 0 12px;" > < i class = "fas fa-mouse-pointer mr-1" > < / i > 点击地图选择位置,或手动输入坐标< / p >
< div class = "grid grid-cols-2 gap-3" >
< div class = "form-group" > < label > 纬度< / label > < input type = "number" id = "editBeaconLat" step = "0.000001" value = "${b.latitude != null ? b.latitude : ''}" > < / div >
< div class = "form-group" > < label > 经度< / label > < input type = "number" id = "editBeaconLon" step = "0.000001" value = "${b.longitude != null ? b.longitude : ''}" > < / div >
< / div >
< div class = "form-group" > < label > 详细地址< / label > < input type = "text" id = "editBeaconAddress" value = "${escapeHtml(b.address || '')}" > < p class = "text-xs text-gray-500 mt-1" > 点击地图或搜索位置后自动填充< / p > < / div >
< div class = "form-group" > < label > 状态< / label >
< select id = "editBeaconStatus" >
< option value = "active" $ { b . status = == ' active ' ? ' selected ' : ' ' } > 启用< / option >
< option value = "inactive" $ { b . status ! = = ' active ' ? ' selected ' : ' ' } > 停用< / option >
< / select >
< p class = "text-xs text-gray-500 mt-1" > 停用后蓝牙记录将不再关联此信标位置< / p >
< / div >
< div class = "flex gap-3 mt-6" >
< button class = "btn btn-primary flex-1" onclick = "submitEditBeacon(${id})" > < i class = "fas fa-check" > < / i > 保存< / button >
< button class = "btn btn-secondary flex-1" onclick = "closeModal()" > < i class = "fas fa-times" > < / i > 取消< / button >
< / div >
`);
initBeaconPickerMap('editBeaconMapDiv', 'editBeaconLat', 'editBeaconLon', 'editBeaconAddress',
b.latitude || null, b.longitude || null);
} catch (err) {
showToast('加载信标信息失败: ' + err.message, 'error');
}
}
async function submitEditBeacon(id) {
const body = {};
const name = document.getElementById('editBeaconName').value.trim();
const uuid = document.getElementById('editBeaconUuid').value.trim();
const major = document.getElementById('editBeaconMajor').value;
const minor = document.getElementById('editBeaconMinor').value;
const lat = document.getElementById('editBeaconLat').value;
const lon = document.getElementById('editBeaconLon').value;
const address = document.getElementById('editBeaconAddress').value.trim();
const status = document.getElementById('editBeaconStatus').value;
if (name) body.name = name;
body.beacon_uuid = uuid || null;
if (major !== '') body.beacon_major = parseInt(major); else body.beacon_major = null;
if (minor !== '') body.beacon_minor = parseInt(minor); else body.beacon_minor = null;
if (lat !== '') body.latitude = parseFloat(lat); else body.latitude = null;
if (lon !== '') body.longitude = parseFloat(lon); else body.longitude = null;
body.address = address || null;
body.status = status;
try {
await apiCall(`${API_BASE}/beacons/${id}`, { method: 'PUT', body: JSON.stringify(body) });
showToast('信标已更新');
closeModal();
loadBeacons();
} catch (err) {
showToast('更新失败: ' + err.message, 'error');
}
}
function confirmDeleteBeacon(id, name) {
showModal(`
< h3 class = "text-lg font-semibold mb-4 text-red-400" > < i class = "fas fa-exclamation-triangle mr-2" > < / i > 确认删除< / h3 >
< p class = "text-gray-300 mb-6" > 确定要删除信标 < strong > "${name}"< / strong > 吗?删除后蓝牙记录将不再关联此信标位置。< / p >
< div class = "flex gap-3" >
< button class = "btn btn-danger flex-1" onclick = "deleteBeacon(${id})" > < i class = "fas fa-trash" > < / i > 确认删除< / button >
< button class = "btn btn-secondary flex-1" onclick = "closeModal()" > < i class = "fas fa-times" > < / i > 取消< / button >
< / div >
`);
}
async function deleteBeacon(id) {
try {
await apiCall(`${API_BASE}/beacons/${id}`, { method: 'DELETE' });
showToast('信标已删除');
closeModal();
loadBeacons();
} catch (err) {
showToast('删除失败: ' + err.message, 'error');
}
}
2026-04-01 07:06:37 +00:00
// ==================== BEACON BINDING MATRIX ====================
function switchBeaconTab(tab) {
document.getElementById('beaconTabList').classList.toggle('active', tab === 'list');
document.getElementById('beaconTabBindings').classList.toggle('active', tab === 'bindings');
document.getElementById('beaconTabContentList').style.display = tab === 'list' ? 'flex' : 'none';
document.getElementById('beaconTabContentBindings').style.display = tab === 'bindings' ? 'flex' : 'none';
if (tab === 'bindings') loadBeaconBindingMatrix();
}
let _bbMatrixState = {};
let _bbMatrixOriginal = {};
let _bbBeacons = [];
let _bbDevices = [];
async function loadBeaconBindingMatrix() {
const thead = document.getElementById('beaconBindMatrixHead');
const tbody = document.getElementById('beaconBindMatrixBody');
try {
const [beaconData, deviceData] = await Promise.all([
apiCall(`${API_BASE}/beacons?page=1& page_size=100`),
apiCall(`${API_BASE}/devices?page=1& page_size=100`),
]);
_bbBeacons = beaconData.items || [];
_bbDevices = deviceData.items || [];
if (!_bbBeacons.length) {
thead.innerHTML = '';
tbody.innerHTML = '< tr > < td class = "text-center text-gray-500 py-4" > 暂无信标,请先添加< / td > < / tr > ';
return;
}
if (!_bbDevices.length) {
thead.innerHTML = '';
tbody.innerHTML = '< tr > < td class = "text-center text-gray-500 py-4" > 暂无设备< / td > < / tr > ';
return;
}
const bindingsArr = await Promise.all(
_bbBeacons.map(b => apiCall(`${API_BASE}/beacons/${b.id}/devices`).catch(() => []))
);
_bbMatrixState = {};
_bbMatrixOriginal = {};
_bbBeacons.forEach((b, i) => {
const bound = bindingsArr[i] || [];
const boundIds = new Set(bound.map(x => x.device_id));
_bbDevices.forEach(d => {
const key = `${b.id}-${d.id}`;
const val = boundIds.has(d.id);
_bbMatrixState[key] = val;
_bbMatrixOriginal[key] = val;
});
});
thead.innerHTML = `< tr >
< th style = "position:sticky;left:0;background:#1f2937;z-index:2;min-width:120px" > 设备 \\ 信标< / th >
${_bbBeacons.map(b => `< th style = "text-align:center;min-width:80px;font-size:11px;white-space:nowrap" title = "${escapeHtml(b.beacon_mac)}" > ${escapeHtml(b.name)}< br > < span style = "color:#6b7280;font-weight:normal" > ${b.floor || ''} ${b.area || ''}< / span > < / th > `).join('')}
< th style = "text-align:center;min-width:60px" > 全选< / th >
< / tr > `;
tbody.innerHTML = _bbDevices.map(d => {
const label = d.name || d.imei || d.id;
const statusDot = d.status === 'online' ? '🟢' : '⚪';
return `< tr >
< td style = "position:sticky;left:0;background:#111827;z-index:1;white-space:nowrap;font-size:12px" > ${statusDot} ${escapeHtml(String(label))}< / td >
${_bbBeacons.map(b => {
const key = `${b.id}-${d.id}`;
const checked = _bbMatrixState[key] ? 'checked' : '';
return `< td style = "text-align:center" > < input type = "checkbox" $ { checked } onchange = "_bbMatrixState['${key}']=this.checked;_updateBBSaveBtn()" > < / td > `;
}).join('')}
< td style = "text-align:center" > < input type = "checkbox" onchange = "_bbToggleDeviceRow(${d.id},this.checked)" > < / td >
< / tr > `;
}).join('') + `< tr style = "border-top:1px solid #374151" >
< td style = "position:sticky;left:0;background:#111827;z-index:1;font-size:12px;color:#9ca3af" > 全选列< / td >
${_bbBeacons.map(b => `< td style = "text-align:center" > < input type = "checkbox" onchange = "_bbToggleBeaconCol(${b.id},this.checked)" > < / td > `).join('')}
< td style = "text-align:center" > < input type = "checkbox" onchange = "_bbToggleAll(this.checked)" > < / td >
< / tr > `;
_updateBBSaveBtn();
} catch (err) {
thead.innerHTML = '';
tbody.innerHTML = `< tr > < td class = "text-center text-red-400 py-4" > 加载失败: ${escapeHtml(err.message)}< / td > < / tr > `;
}
}
function _bbToggleDeviceRow(deviceId, checked) {
_bbBeacons.forEach(b => { _bbMatrixState[`${b.id}-${deviceId}`] = checked; });
_bbRefreshCheckboxes();
}
function _bbToggleBeaconCol(beaconId, checked) {
_bbDevices.forEach(d => { _bbMatrixState[`${beaconId}-${d.id}`] = checked; });
_bbRefreshCheckboxes();
}
function _bbToggleAll(checked) {
_bbBeacons.forEach(b => _bbDevices.forEach(d => { _bbMatrixState[`${b.id}-${d.id}`] = checked; }));
_bbRefreshCheckboxes();
}
function _bbRefreshCheckboxes() {
const tbody = document.getElementById('beaconBindMatrixBody');
tbody.querySelectorAll('input[type="checkbox"]').forEach(cb => {
const onchange = cb.getAttribute('onchange') || '';
const m = onchange.match(/_bbMatrixState\['(\d+-\d+)'\]/);
if (m) cb.checked = _bbMatrixState[m[1]] || false;
});
_updateBBSaveBtn();
}
function _updateBBSaveBtn() {
let changes = 0;
for (const key in _bbMatrixState) {
if (_bbMatrixState[key] !== _bbMatrixOriginal[key]) changes++;
}
const btn = document.getElementById('beaconBindSaveBtn');
if (btn) btn.innerHTML = changes > 0
? `< i class = "fas fa-save" > < / i > 保存更改 (${changes})`
: `< i class = "fas fa-save" > < / i > 保存更改`;
}
async function saveBeaconBindingMatrix() {
const btn = document.getElementById('beaconBindSaveBtn');
if (btn & & btn.disabled) return;
if (btn) { btn.disabled = true; btn.innerHTML = '< i class = "fas fa-spinner fa-spin" > < / i > 保存中...'; }
const toBind = {};
const toUnbind = {};
const affectedDeviceIds = new Set();
for (const key in _bbMatrixState) {
if (_bbMatrixState[key] === _bbMatrixOriginal[key]) continue;
const [beaconId, deviceId] = key.split('-').map(Number);
affectedDeviceIds.add(deviceId);
if (_bbMatrixState[key]) {
(toBind[beaconId] = toBind[beaconId] || []).push(deviceId);
} else {
(toUnbind[beaconId] = toUnbind[beaconId] || []).push(deviceId);
}
}
const ops = [];
for (const bid in toBind) {
ops.push(apiCall(`${API_BASE}/beacons/${bid}/devices`, {
method: 'POST', body: JSON.stringify({ device_ids: toBind[bid] }),
}));
}
for (const bid in toUnbind) {
ops.push(apiCall(`${API_BASE}/beacons/${bid}/devices`, {
method: 'DELETE', body: JSON.stringify({ device_ids: toUnbind[bid] }),
}));
}
if (!ops.length) {
showToast('没有更改', 'info');
if (btn) { btn.disabled = false; btn.innerHTML = '< i class = "fas fa-save" > < / i > 保存更改'; }
return;
}
try {
await Promise.all(ops);
if (btn) btn.innerHTML = '< i class = "fas fa-spinner fa-spin" > < / i > 同步指令到设备...';
// Sync BTMACSET commands to each affected device
const syncResults = await Promise.all(
[...affectedDeviceIds].map(did =>
apiCall(`${API_BASE}/beacons/sync-device/${did}`, { method: 'POST' }).catch(e => ({ error: e.message }))
)
);
let sentCount = 0, failCount = 0;
syncResults.forEach(r => {
if (r & & r.sent) sentCount++;
else failCount++;
});
if (failCount > 0) {
showToast(`已保存绑定。指令同步: ${sentCount} 台成功, ${failCount} 台失败(可能离线)`, 'warning');
} else {
showToast(`已保存绑定并同步 BTMACSET 指令到 ${sentCount} 台设备`);
}
loadBeaconBindingMatrix();
} catch (err) {
showToast('保存失败: ' + err.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '< i class = "fas fa-save" > < / i > 保存更改'; }
}
}
async function _bbReverseSync() {
const btn = document.getElementById('bbReverseSyncBtn');
btn.disabled = true;
btn.innerHTML = '< i class = "fas fa-spinner fa-spin" > < / i > 查询设备中...';
try {
const result = await apiCall(`${API_BASE}/beacons/reverse-sync`, { method: 'POST' });
const d = result;
if (d.error) {
showToast(d.error, 'error');
return;
}
// Build detail summary
let msg = `查询 ${d.queried} 台,${d.responded} 台响应,${d.updated} 台有变更`;
if (d.details & & d.details.length) {
const lines = d.details.map(x => {
const label = x.name || x.imei;
if (x.status === '无响应') return `${label}: 无响应`;
return `${label}: ${x.device_macs.length}个MAC, 匹配${x.matched_beacons}个信标` +
(x.added || x.removed ? ` (+${x.added} -${x.removed})` : ' 无变更');
});
msg += '\n' + lines.join('\n');
}
showToast(msg, d.updated > 0 ? 'success' : 'info');
loadBeaconBindingMatrix();
} catch (err) {
showToast('同步失败: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '< i class = "fas fa-download" > < / i > 从设备同步';
}
}
2026-03-27 10:19:34 +00:00
// ==================== COMMANDS ====================
async function sendCommand() {
2026-03-31 05:01:04 +00:00
const deviceId = document.getElementById('cmdUnifiedDevice').value;
2026-03-27 10:19:34 +00:00
const commandType = document.getElementById('cmdType').value.trim();
const commandContent = document.getElementById('cmdContent').value.trim();
if (!deviceId) { showToast('请选择设备', 'error'); return; }
if (!commandType) { showToast('请输入指令类型', 'error'); return; }
try {
await apiCall(`${API_BASE}/commands/send`, {
method: 'POST',
body: JSON.stringify({ device_id: parseInt(deviceId), command_type: commandType, command_content: commandContent }),
});
showToast('指令已发送');
document.getElementById('cmdType').value = '';
document.getElementById('cmdContent').value = '';
loadCommands();
} catch (err) {
showToast('发送指令失败: ' + err.message, 'error');
}
}
async function sendMessage() {
2026-03-31 05:01:04 +00:00
const deviceId = document.getElementById('cmdUnifiedDevice').value;
2026-03-27 10:19:34 +00:00
const message = document.getElementById('msgContent').value.trim();
if (!deviceId) { showToast('请选择设备', 'error'); return; }
if (!message) { showToast('请输入消息内容', 'error'); return; }
try {
await apiCall(`${API_BASE}/commands/message`, {
method: 'POST',
body: JSON.stringify({ device_id: parseInt(deviceId), message }),
});
showToast('消息已发送');
document.getElementById('msgContent').value = '';
loadCommands();
} catch (err) {
showToast('发送消息失败: ' + err.message, 'error');
}
}
// TTS character counter
const ttsTextarea = document.getElementById('ttsContent');
if (ttsTextarea) {
ttsTextarea.addEventListener('input', () => {
const count = ttsTextarea.value.length;
const el = document.getElementById('ttsCharCount');
if (el) {
el.textContent = `${count}/200`;
el.style.color = count > 200 ? '#ef4444' : '#6b7280';
}
});
}
async function sendTTS() {
2026-03-31 05:01:04 +00:00
const deviceId = document.getElementById('cmdUnifiedDevice').value;
2026-03-27 10:19:34 +00:00
const text = document.getElementById('ttsContent').value.trim();
if (!deviceId) { showToast('请选择目标设备', 'error'); return; }
if (!text) { showToast('请输入语音播报内容', 'error'); return; }
if (text.length > 200) { showToast('播报文本不能超过 200 字', 'error'); return; }
try {
await apiCall(`${API_BASE}/commands/tts`, {
method: 'POST',
body: JSON.stringify({ device_id: parseInt(deviceId), text }),
});
showToast('语音已下发');
document.getElementById('ttsContent').value = '';
document.getElementById('ttsCharCount').textContent = '0/200';
loadCommands();
} catch (err) {
showToast('语音下发失败: ' + err.message, 'error');
}
}
2026-03-31 10:11:33 +00:00
async function loadCommandStats() {
try {
const stats = await apiCall(`${API_BASE}/commands/stats`);
document.getElementById('cmdStatTotal').textContent = stats.total || 0;
document.getElementById('cmdStatToday').textContent = `今日 ${stats.today || 0}`;
document.getElementById('cmdStatRate').textContent = (stats.success_rate || 0) + '%';
document.getElementById('cmdStatSent').textContent = (stats.by_status?.sent || 0) + (stats.by_status?.success || 0);
document.getElementById('cmdStatFailed').textContent = stats.by_status?.failed || 0;
} catch (err) {
console.error('Failed to load command stats:', err);
}
}
2026-03-27 10:19:34 +00:00
async function loadCommands(page) {
if (page) pageState.commands.page = page;
const p = pageState.commands.page;
const ps = pageState.commands.pageSize;
const deviceId = document.getElementById('cmdHistoryDeviceFilter').value;
const status = document.getElementById('cmdStatusFilter').value;
let url = `${API_BASE}/commands?page=${p}&page_size=${ps}`;
if (deviceId) url += `&device_id=${deviceId}`;
if (status) url += `&status=${status}`;
showLoading('commandsLoading');
try {
const data = await apiCall(url);
const items = data.items || [];
const tbody = document.getElementById('commandsTableBody');
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(c => `
< tr >
< td class = "font-mono text-xs" > ${escapeHtml(c.id || '-')}< / td >
2026-03-31 09:41:09 +00:00
< td class = "font-mono text-xs" > ${escapeHtml(_imei(c.device_id))}< / td >
2026-03-27 10:19:34 +00:00
< 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 >
< td class = "text-xs" style = "max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title = "${escapeHtml(c.response_content || '')}" > ${escapeHtml(truncate(c.response_content || '-', 30))}< / td >
< td class = "text-xs text-gray-400" > ${formatTime(c.created_at)}< / td >
< td class = "text-xs text-gray-400" > ${formatTime(c.response_at || c.sent_at)}< / td >
< / tr >
`).join('');
}
renderPagination('commandsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadCommands');
} catch (err) {
showToast('加载指令记录失败: ' + err.message, 'error');
document.getElementById('commandsTableBody').innerHTML = '< tr > < td colspan = "8" class = "text-center text-red-400 py-8" > 加载失败< / td > < / tr > ';
} finally {
hideLoading('commandsLoading');
}
}
2026-04-01 07:06:37 +00:00
// ==================== ALARM CONDITIONAL DELETE ====================
function showAlarmCleanupModal() {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-broom mr-2 text-red-400" > < / i > 按条件批量删除告警< / h3 >
< div class = "form-group" > < label > 设备< / label >
< select id = "alarmCleanDevice" style = "width:100%" > < option value = "" > 全部设备< / option > < / select > < / div >
< div class = "form-group" > < label > 告警类型< / label >
< select id = "alarmCleanType" style = "width:100%" > < option value = "" > 全部类型< / option >
< option value = "sos" > SOS< / option > < option value = "low_battery" > 低电量< / option >
< option value = "vibration" > 振动< / option > < option value = "power_cut" > 断电< / option >
< option value = "enter_fence" > 进围栏< / option > < option value = "exit_fence" > 出围栏< / option >
< option value = "power_on" > 开机< / option > < option value = "power_off" > 关机< / option >
< / select > < / div >
< div class = "form-group" > < label > 确认状态< / label >
< select id = "alarmCleanAck" style = "width:100%" > < option value = "" > 全部< / option >
< option value = "true" > 已确认< / option > < option value = "false" > 未确认< / option > < / select > < / div >
< div class = "grid grid-cols-2 gap-3" >
< div class = "form-group" > < label > 开始时间< / label > < input type = "date" id = "alarmCleanStart" style = "width:100%" > < / div >
< div class = "form-group" > < label > 结束时间< / label > < input type = "date" id = "alarmCleanEnd" style = "width:100%" > < / div >
< / div >
< p class = "text-xs text-red-400 mb-4" > < i class = "fas fa-exclamation-triangle" > < / i > 此操作不可撤销!请确认筛选条件。< / p >
< div class = "flex gap-3" >
< button class = "btn flex-1" style = "background:#dc2626;color:#fff" onclick = "submitAlarmCleanup()" > < i class = "fas fa-trash-alt" > < / i > 确认删除< / button >
< button class = "btn btn-secondary flex-1" onclick = "closeModal()" > < i class = "fas fa-times" > < / i > 取消< / button >
< / div >
`);
// populate device selector
if (cachedDevices) {
const sel = document.getElementById('alarmCleanDevice');
cachedDevices.forEach(d => { const o = document.createElement('option'); o.value = d.id; o.textContent = d.name || d.imei; sel.appendChild(o); });
}
}
async function submitAlarmCleanup() {
const body = {};
const did = document.getElementById('alarmCleanDevice').value;
const at = document.getElementById('alarmCleanType').value;
const ack = document.getElementById('alarmCleanAck').value;
const sd = document.getElementById('alarmCleanStart').value;
const ed = document.getElementById('alarmCleanEnd').value;
if (did) body.device_id = parseInt(did);
if (at) body.alarm_type = at;
if (ack) body.acknowledged = ack === 'true';
if (sd) body.start_time = sd + 'T00:00:00';
if (ed) body.end_time = ed + 'T23:59:59';
if (!Object.keys(body).length) { showToast('请至少选择一个筛选条件', 'info'); return; }
if (!confirm('确定按所选条件删除告警记录?此操作不可撤销!')) return;
try {
const result = await apiCall(`${API_BASE}/alarms/batch-delete`, { method: 'POST', body: JSON.stringify(body) });
showToast(`已删除 ${result.deleted} 条告警`);
closeModal(); loadAlarms();
} catch (err) { showToast('删除失败: ' + err.message, 'error'); }
}
// ==================== LOCATION CLEANUP ====================
function showLocationCleanupModal() {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-clock mr-2 text-yellow-400" > < / i > 清理旧位置数据< / h3 >
< div class = "form-group" > < label > 删除多少天前的数据 < span class = "text-red-400" > *< / span > < / label >
< input type = "number" id = "cleanupDays" value = "30" min = "1" max = "3650" style = "width:100%" >
< p class = "text-xs text-gray-500 mt-1" > 将删除指定天数之前的位置记录< / p > < / div >
< div class = "form-group" > < label > 设备 (可选)< / label >
< select id = "cleanupDevice" style = "width:100%" > < option value = "" > 全部设备< / option > < / select > < / div >
< div class = "form-group" > < label > 定位类型 (可选)< / label >
< select id = "cleanupLocType" style = "width:100%" > < option value = "" > 全部类型< / option >
< option value = "lbs" > LBS (基站)< / option > < option value = "lbs_4g" > LBS 4G< / option >
< option value = "wifi" > WiFi< / option > < option value = "wifi_4g" > WiFi 4G< / option >
< option value = "gps" > GPS< / option > < option value = "gps_4g" > GPS 4G< / option >
< / select > < / div >
< p class = "text-xs text-red-400 mb-4" > < i class = "fas fa-exclamation-triangle" > < / i > 此操作不可撤销!< / p >
< div class = "flex gap-3" >
< button class = "btn flex-1" style = "background:#f59e0b;color:#000" onclick = "submitLocationCleanup()" > < i class = "fas fa-broom" > < / i > 确认清理< / button >
< button class = "btn btn-secondary flex-1" onclick = "closeModal()" > < i class = "fas fa-times" > < / i > 取消< / button >
< / div >
`);
if (cachedDevices) {
const sel = document.getElementById('cleanupDevice');
cachedDevices.forEach(d => { const o = document.createElement('option'); o.value = d.id; o.textContent = d.name || d.imei; sel.appendChild(o); });
}
}
async function submitLocationCleanup() {
const days = parseInt(document.getElementById('cleanupDays').value);
if (!days || days < 1 ) { showToast ( ' 请输入有效的天数 ' , ' info ' ) ; return ; }
const body = { days };
const did = document.getElementById('cleanupDevice').value;
const lt = document.getElementById('cleanupLocType').value;
if (did) body.device_id = parseInt(did);
if (lt) body.location_type = lt;
if (!confirm(`确定删除 ${days} 天前的位置记录?此操作不可撤销!`)) return;
try {
const result = await apiCall(`${API_BASE}/locations/cleanup`, { method: 'POST', body: JSON.stringify(body) });
showToast(`已清理 ${result.deleted} 条记录`);
closeModal(); loadLocationRecords();
} catch (err) { showToast('清理失败: ' + err.message, 'error'); }
}
// ==================== DEVICE GROUPS ====================
async function showDeviceGroupsModal() {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-layer-group mr-2 text-blue-400" > < / i > 设备分组管理< / h3 >
< div id = "groupListContainer" class = "mb-4" style = "max-height:300px;overflow-y:auto" > < div class = "spinner" style = "margin:20px auto" > < / div > < / div >
< div class = "flex gap-3" >
< button class = "btn btn-primary flex-1" onclick = "showCreateGroupForm()" > < i class = "fas fa-plus" > < / i > 新建分组< / button >
< button class = "btn btn-secondary flex-1" onclick = "closeModal()" > < i class = "fas fa-times" > < / i > 关闭< / button >
< / div >
`);
await loadGroupList();
}
async function loadGroupList() {
try {
const groups = await apiCall(`${API_BASE}/groups`);
const container = document.getElementById('groupListContainer');
if (!groups || !groups.length) {
container.innerHTML = '< p class = "text-gray-500 text-center py-4" > 暂无分组< / p > ';
return;
}
container.innerHTML = groups.map(g => `
< div class = "flex items-center justify-between p-3 rounded-lg mb-2" style = "background:#374151;border-left:4px solid ${g.color}" >
< div >
< span class = "font-medium" > ${g.name}< / span >
< span class = "text-xs text-gray-400 ml-2" > ${g.device_count} 台设备< / span >
${g.description ? `< p class = "text-xs text-gray-500 mt-1" > ${g.description}< / p > ` : ''}
< / div >
< div class = "flex gap-2" >
< button class = "btn btn-secondary" style = "padding:4px 8px;font-size:12px" onclick = "showGroupDevices(${g.id},'${g.name}')" title = "管理成员" > < i class = "fas fa-users" > < / i > < / button >
< button class = "btn" style = "padding:4px 8px;font-size:12px;background:#dc2626;color:#fff" onclick = "deleteGroup(${g.id})" title = "删除分组" > < i class = "fas fa-trash" > < / i > < / button >
< / div >
< / div >
`).join('');
} catch (err) { showToast('加载分组失败: ' + err.message, 'error'); }
}
function showCreateGroupForm() {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-plus-circle mr-2 text-blue-400" > < / i > 新建分组< / h3 >
< div class = "form-group" > < label > 名称 < span class = "text-red-400" > *< / span > < / label >
< input type = "text" id = "newGroupName" placeholder = "如: A区工人" maxlength = "100" style = "width:100%" > < / div >
< div class = "form-group" > < label > 描述< / label >
< input type = "text" id = "newGroupDesc" placeholder = "可选描述" maxlength = "500" style = "width:100%" > < / div >
< div class = "form-group" > < label > 颜色< / label >
< input type = "color" id = "newGroupColor" value = "#3b82f6" style = "width:60px;height:36px" > < / div >
< div class = "flex gap-3 mt-4" >
< button class = "btn btn-primary flex-1" onclick = "submitCreateGroup()" > < i class = "fas fa-check" > < / i > 创建< / button >
< button class = "btn btn-secondary flex-1" onclick = "showDeviceGroupsModal()" > < i class = "fas fa-arrow-left" > < / i > 返回< / button >
< / div >
`);
}
async function submitCreateGroup() {
const name = document.getElementById('newGroupName').value.trim();
if (!name) { showToast('请输入分组名称', 'info'); return; }
try {
await apiCall(`${API_BASE}/groups`, { method: 'POST', body: JSON.stringify({
name, description: document.getElementById('newGroupDesc').value.trim() || null,
color: document.getElementById('newGroupColor').value,
})});
showToast('分组创建成功');
showDeviceGroupsModal();
} catch (err) { showToast('创建失败: ' + err.message, 'error'); }
}
async function deleteGroup(groupId) {
if (!confirm('确定删除此分组?(不会删除设备)')) return;
try {
await apiCall(`${API_BASE}/groups/${groupId}`, { method: 'DELETE' });
showToast('分组已删除'); loadGroupList();
} catch (err) { showToast('删除失败: ' + err.message, 'error'); }
}
async function showGroupDevices(groupId, groupName) {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-users mr-2 text-blue-400" > < / i > ${groupName} - 设备列表< / h3 >
< div id = "groupDeviceList" style = "max-height:250px;overflow-y:auto" > < div class = "spinner" style = "margin:20px auto" > < / div > < / div >
< div class = "form-group mt-4" > < label > 添加设备到分组< / label >
< select id = "addToGroupDevice" style = "width:100%" > < option value = "" > 选择设备...< / option > < / select > < / div >
< div class = "flex gap-3" >
< button class = "btn btn-primary flex-1" onclick = "addDeviceToGroup(${groupId})" > < i class = "fas fa-plus" > < / i > 添加< / button >
< button class = "btn btn-secondary flex-1" onclick = "showDeviceGroupsModal()" > < i class = "fas fa-arrow-left" > < / i > 返回< / button >
< / div >
`);
// Load group devices
try {
const devices = await apiCall(`${API_BASE}/groups/${groupId}/devices`);
const container = document.getElementById('groupDeviceList');
if (!devices.length) { container.innerHTML = '< p class = "text-gray-500 text-center py-3" > 暂无设备< / p > '; }
else {
container.innerHTML = devices.map(d => `
< div class = "flex items-center justify-between p-2 rounded mb-1" style = "background:#374151" >
< span > ${d.name || d.imei} < span class = "text-xs text-gray-400" > (${d.imei})< / span > < / span >
< button class = "btn" style = "padding:2px 6px;font-size:11px;background:#dc2626;color:#fff" onclick = "removeDeviceFromGroup(${groupId},${d.id},'${groupName}')" > < i class = "fas fa-times" > < / i > < / button >
< / div >
`).join('');
}
} catch (err) { document.getElementById('groupDeviceList').innerHTML = '< p class = "text-red-400" > 加载失败< / p > '; }
// Populate device selector (exclude already in group)
if (cachedDevices) {
const sel = document.getElementById('addToGroupDevice');
cachedDevices.forEach(d => { const o = document.createElement('option'); o.value = d.id; o.textContent = (d.name || d.imei) + ' (' + d.imei + ')'; sel.appendChild(o); });
}
}
async function addDeviceToGroup(groupId) {
const did = document.getElementById('addToGroupDevice').value;
if (!did) { showToast('请选择设备', 'info'); return; }
try {
await apiCall(`${API_BASE}/groups/${groupId}/devices`, { method: 'POST', body: JSON.stringify({ device_ids: [parseInt(did)] }) });
showToast('设备已添加');
const groupName = document.querySelector('#modalContainer h3')?.textContent?.split(' - ')[0]?.replace(/.*\s/, '') || '';
showGroupDevices(groupId, groupName);
} catch (err) { showToast('添加失败: ' + err.message, 'error'); }
}
async function removeDeviceFromGroup(groupId, deviceId, groupName) {
try {
await apiCall(`${API_BASE}/groups/${groupId}/devices`, { method: 'DELETE', body: JSON.stringify({ device_ids: [deviceId] }) });
showToast('设备已移除');
showGroupDevices(groupId, groupName);
} catch (err) { showToast('移除失败: ' + err.message, 'error'); }
}
// ==================== ALERT RULES ====================
async function showAlertRulesModal() {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-cog mr-2 text-blue-400" > < / i > 告警规则管理< / h3 >
< div id = "alertRuleList" style = "max-height:350px;overflow-y:auto" > < div class = "spinner" style = "margin:20px auto" > < / div > < / div >
< div class = "flex gap-3 mt-4" >
< button class = "btn btn-primary flex-1" onclick = "showCreateAlertRuleForm()" > < i class = "fas fa-plus" > < / i > 新建规则< / button >
< button class = "btn btn-secondary flex-1" onclick = "closeModal()" > < i class = "fas fa-times" > < / i > 关闭< / button >
< / div >
`);
await loadAlertRules();
}
async function loadAlertRules() {
try {
const rules = await apiCall(`${API_BASE}/alert-rules`);
const container = document.getElementById('alertRuleList');
const typeLabels = { low_battery: '低电量', no_heartbeat: '心跳超时', fence_stay: '围栏停留', speed_limit: '超速', offline_duration: '离线时长' };
if (!rules || !rules.length) { container.innerHTML = '< p class = "text-gray-500 text-center py-4" > 暂无规则< / p > '; return; }
container.innerHTML = rules.map(r => `
< div class = "flex items-center justify-between p-3 rounded-lg mb-2" style = "background:#374151" >
< div >
< span class = "font-medium" > ${r.name}< / span >
< span class = "text-xs px-2 py-0.5 rounded ml-2" style = "background:${r.is_active ? '#059669' : '#6b7280'};color:#fff" > ${r.is_active ? '启用' : '禁用'}< / span >
< p class = "text-xs text-gray-400 mt-1" > 类型: ${typeLabels[r.rule_type] || r.rule_type} | 条件: ${JSON.stringify(r.conditions)}< / p >
${r.description ? `< p class = "text-xs text-gray-500" > ${r.description}< / p > ` : ''}
< / div >
< div class = "flex gap-2" >
< button class = "btn btn-secondary" style = "padding:4px 8px;font-size:12px" onclick = "toggleAlertRule(${r.id},${!r.is_active})" title = "${r.is_active ? '禁用' : '启用'}" > < i class = "fas fa-${r.is_active ? 'pause' : 'play'}" > < / i > < / button >
< button class = "btn" style = "padding:4px 8px;font-size:12px;background:#dc2626;color:#fff" onclick = "deleteAlertRule(${r.id})" title = "删除" > < i class = "fas fa-trash" > < / i > < / button >
< / div >
< / div >
`).join('');
} catch (err) { showToast('加载规则失败: ' + err.message, 'error'); }
}
function showCreateAlertRuleForm() {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-plus-circle mr-2 text-blue-400" > < / i > 新建告警规则< / h3 >
< div class = "form-group" > < label > 名称 < span class = "text-red-400" > *< / span > < / label >
< input type = "text" id = "newRuleName" placeholder = "如: 低电量报警" maxlength = "100" style = "width:100%" > < / div >
< div class = "form-group" > < label > 规则类型 < span class = "text-red-400" > *< / span > < / label >
< select id = "newRuleType" style = "width:100%" onchange = "updateRuleConditionHint()" >
< option value = "low_battery" > 低电量< / option >
< option value = "no_heartbeat" > 心跳超时< / option >
< option value = "fence_stay" > 围栏停留< / option >
< option value = "speed_limit" > 超速< / option >
< option value = "offline_duration" > 离线时长< / option >
< / select > < / div >
< div class = "form-group" > < label > 阈值 < span class = "text-red-400" > *< / span > < / label >
< input type = "number" id = "newRuleThreshold" value = "20" min = "1" style = "width:100%" >
< p class = "text-xs text-gray-500 mt-1" id = "ruleCondHint" > 电量百分比 (低于此值触发告警)< / p > < / div >
< div class = "form-group" > < label > 描述< / label >
< input type = "text" id = "newRuleDesc" placeholder = "可选描述" style = "width:100%" > < / div >
< div class = "flex gap-3 mt-4" >
< button class = "btn btn-primary flex-1" onclick = "submitCreateAlertRule()" > < i class = "fas fa-check" > < / i > 创建< / button >
< button class = "btn btn-secondary flex-1" onclick = "showAlertRulesModal()" > < i class = "fas fa-arrow-left" > < / i > 返回< / button >
< / div >
`);
}
function updateRuleConditionHint() {
const type = document.getElementById('newRuleType').value;
const hints = {
low_battery: '电量百分比 (低于此值触发告警)',
no_heartbeat: '分钟数 (超过此时长无心跳触发)',
fence_stay: '分钟数 (围栏内停留超过此时长触发)',
speed_limit: '速度km/h (超过此速度触发)',
offline_duration: '分钟数 (离线超过此时长触发)',
};
document.getElementById('ruleCondHint').textContent = hints[type] || '';
}
async function submitCreateAlertRule() {
const name = document.getElementById('newRuleName').value.trim();
const ruleType = document.getElementById('newRuleType').value;
const threshold = parseInt(document.getElementById('newRuleThreshold').value);
if (!name) { showToast('请输入规则名称', 'info'); return; }
if (!threshold || threshold < 1 ) { showToast ( ' 请输入有效阈值 ' , ' info ' ) ; return ; }
try {
await apiCall(`${API_BASE}/alert-rules`, { method: 'POST', body: JSON.stringify({
name, rule_type: ruleType,
conditions: { threshold },
description: document.getElementById('newRuleDesc').value.trim() || null,
})});
showToast('规则创建成功');
showAlertRulesModal();
} catch (err) { showToast('创建失败: ' + err.message, 'error'); }
}
async function toggleAlertRule(ruleId, active) {
try {
await apiCall(`${API_BASE}/alert-rules/${ruleId}`, { method: 'PUT', body: JSON.stringify({ is_active: active }) });
showToast(active ? '规则已启用' : '规则已禁用');
loadAlertRules();
} catch (err) { showToast('操作失败: ' + err.message, 'error'); }
}
async function deleteAlertRule(ruleId) {
if (!confirm('确定删除此告警规则?')) return;
try {
await apiCall(`${API_BASE}/alert-rules/${ruleId}`, { method: 'DELETE' });
showToast('规则已删除'); loadAlertRules();
} catch (err) { showToast('删除失败: ' + err.message, 'error'); }
}
// ==================== SYSTEM MANAGEMENT ====================
// --- System Config Modal ---
async function showSystemConfigModal() {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-cog mr-2 text-blue-400" > < / i > 系统运行时配置< / h3 >
< div id = "sysConfigForm" >
< div class = "spinner" style = "margin:20px auto" > < / div >
< / div >
< div class = "flex gap-3 mt-4" >
< button class = "btn btn-primary flex-1" onclick = "submitSystemConfig()" > < i class = "fas fa-save mr-1" > < / i > 保存配置< / button >
< button class = "btn flex-1" onclick = "closeModal()" > 关闭< / button >
< / div >
< p class = "text-xs text-gray-500 mt-2" > < i class = "fas fa-info-circle mr-1" > < / i > 修改仅影响当前进程,重启后恢复 .env 配置值< / p >
`, { maxWidth: '550px' });
try {
const c = await apiCall(`${API_BASE}/system/config`);
document.getElementById('sysConfigForm').innerHTML = `
< div class = "grid grid-cols-2 gap-3" >
< div class = "form-group" > < label > 数据保留天数< / label > < input id = "cfgRetDays" type = "number" min = "1" max = "3650" value = "${c.data_retention_days}" > < / div >
< div class = "form-group" > < label > 清理间隔(小时)< / label > < input id = "cfgCleanInt" type = "number" min = "1" max = "720" value = "${c.data_cleanup_interval_hours}" > < / div >
< div class = "form-group" > < label > TCP空闲超时(秒)< / label > < input id = "cfgTcpTimeout" type = "number" min = "0" max = "86400" value = "${c.tcp_idle_timeout}" > < / div >
< div class = "form-group" > < label > 轨迹最大点数< / label > < input id = "cfgTrackMax" type = "number" min = "100" max = "100000" value = "${c.track_max_points}" > < / div >
< div class = "form-group" > < label > 围栏LBS容差(米)< / label > < input id = "cfgFenceLbs" type = "number" min = "0" max = "10000" value = "${c.fence_lbs_tolerance_meters}" > < / div >
< div class = "form-group" > < label > 围栏WiFi容差(米)< / label > < input id = "cfgFenceWifi" type = "number" min = "0" max = "10000" value = "${c.fence_wifi_tolerance_meters}" > < / div >
< div class = "form-group" > < label > 围栏防抖(秒)< / label > < input id = "cfgFenceDebounce" type = "number" min = "0" max = "3600" value = "${c.fence_min_inside_seconds}" > < / div >
< div class = "form-group" > < label > 围栏检查< / label > < select id = "cfgFenceCheck" > < option value = "true" $ { c . fence_check_enabled ? ' selected ' : ' ' } > 启用< / option > < option value = "false" $ { ! c . fence_check_enabled ? ' selected ' : ' ' } > 禁用< / option > < / select > < / div >
< / div >
< div class = "mt-2 p-2 rounded" style = "background:#1e293b" >
< p class = "text-xs text-gray-400" > < i class = "fas fa-lock mr-1" > < / i > 只读: 默认限流 ${c.rate_limit_default}, 写限流 ${c.rate_limit_write}, 缓存 ${c.geocoding_cache_size}< / p >
< / div >
`;
} catch (err) { document.getElementById('sysConfigForm').innerHTML = '< p class = "text-red-400" > 加载失败: ' + err.message + '< / p > '; }
}
async function submitSystemConfig() {
const body = {
data_retention_days: parseInt(document.getElementById('cfgRetDays').value),
data_cleanup_interval_hours: parseInt(document.getElementById('cfgCleanInt').value),
tcp_idle_timeout: parseInt(document.getElementById('cfgTcpTimeout').value),
track_max_points: parseInt(document.getElementById('cfgTrackMax').value),
fence_lbs_tolerance_meters: parseInt(document.getElementById('cfgFenceLbs').value),
fence_wifi_tolerance_meters: parseInt(document.getElementById('cfgFenceWifi').value),
fence_min_inside_seconds: parseInt(document.getElementById('cfgFenceDebounce').value),
fence_check_enabled: document.getElementById('cfgFenceCheck').value === 'true',
};
try {
await apiCall(`${API_BASE}/system/config`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
showToast('配置已保存 (进程级)');
closeModal();
} catch (err) { showToast('保存失败: ' + err.message, 'error'); }
}
// --- Audit Log Modal ---
async function showAuditLogModal() {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-history mr-2 text-cyan-400" > < / i > 操作审计日志< / h3 >
< div class = "flex gap-2 mb-3 items-end flex-wrap" >
< div class = "form-group" style = "margin:0;flex:1;min-width:100px" > < label style = "font-size:11px" > 方法< / label >
< select id = "auditMethod" style = "padding:4px 8px;font-size:12px" > < option value = "" > 全部< / option > < option value = "POST" > POST< / option > < option value = "PUT" > PUT< / option > < option value = "DELETE" > DELETE< / option > < / select > < / div >
< div class = "form-group" style = "margin:0;flex:1;min-width:100px" > < label style = "font-size:11px" > 路径< / label >
< input id = "auditPath" placeholder = "关键词" style = "padding:4px 8px;font-size:12px" > < / div >
< button class = "btn btn-sm" style = "padding:4px 10px;font-size:12px;height:30px" onclick = "loadAuditLogs(1)" > < i class = "fas fa-search" > < / i > < / button >
< / div >
< div id = "auditLogContainer" style = "max-height:400px;overflow-y:auto" > < div class = "spinner" style = "margin:20px auto" > < / div > < / div >
< div class = "flex gap-3 mt-3" >
< button class = "btn btn-sm" id = "auditPrevBtn" onclick = "loadAuditLogs(_auditPage-1)" disabled style = "font-size:12px" > < i class = "fas fa-chevron-left" > < / i > < / button >
< span class = "text-xs text-gray-400 flex items-center" id = "auditPageInfo" > -< / span >
< button class = "btn btn-sm" id = "auditNextBtn" onclick = "loadAuditLogs(_auditPage+1)" style = "font-size:12px" > < i class = "fas fa-chevron-right" > < / i > < / button >
< div class = "flex-1" > < / div >
< button class = "btn btn-sm" style = "font-size:12px;color:#ef4444" onclick = "cleanAuditLogs()" > < i class = "fas fa-trash mr-1" > < / i > 清理旧日志< / button >
< button class = "btn btn-sm" onclick = "closeModal()" style = "font-size:12px" > 关闭< / button >
< / div >
`, { maxWidth: '700px' });
loadAuditLogs(1);
}
let _auditPage = 1;
async function loadAuditLogs(page) {
if (page < 1 ) return ;
_auditPage = page;
const method = document.getElementById('auditMethod')?.value || '';
const pathQ = document.getElementById('auditPath')?.value || '';
let url = `${API_BASE}/system/audit-logs?page=${page}&page_size=15`;
if (method) url += `&method=${method}`;
if (pathQ) url += `&path_contains=${encodeURIComponent(pathQ)}`;
try {
const d = await apiCall(url);
const container = document.getElementById('auditLogContainer');
if (!d.items.length) { container.innerHTML = '< p class = "text-gray-500 text-center py-4" > 暂无审计日志< / p > '; }
else {
const methodColors = { POST: '#22c55e', PUT: '#3b82f6', DELETE: '#ef4444' };
container.innerHTML = '< table class = "w-full text-xs" > < thead > < tr > < th > 时间< / th > < th > 方法< / th > < th > 路径< / th > < th > 状态< / th > < th > 操作人< / th > < th > IP< / th > < th > 耗时< / th > < / tr > < / thead > < tbody > ' +
d.items.map(a => `< tr >
< td style = "white-space:nowrap" > ${new Date(a.created_at).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'})}< / td >
< td > < span style = "color:${methodColors[a.method]||'#999'};font-weight:600" > ${a.method}< / span > < / td >
< td style = "max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title = "${a.path}" > ${a.path}< / td >
< td > < span style = "color:${a.status_code<400?'#22c55e':'#ef4444'}" > ${a.status_code}< / span > < / td >
< td > ${a.operator||'-'}< / td >
< td style = "font-size:10px" > ${a.client_ip||'-'}< / td >
< td > ${a.duration_ms!=null?a.duration_ms+'ms':'-'}< / td >
< / tr > `).join('') + '< / tbody > < / table > ';
}
document.getElementById('auditPageInfo').textContent = `${d.page} / ${d.total_pages} (${d.total}条)`;
document.getElementById('auditPrevBtn').disabled = d.page < = 1;
document.getElementById('auditNextBtn').disabled = d.page >= d.total_pages;
} catch (err) { document.getElementById('auditLogContainer').innerHTML = '< p class = "text-red-400" > 加载失败: ' + err.message + '< / p > '; }
}
async function cleanAuditLogs() {
const days = prompt('删除多少天前的审计日志?', '90');
if (!days || isNaN(days) || parseInt(days) < 1 ) return ;
try {
const res = await apiCall(`${API_BASE}/system/audit-logs?days=${parseInt(days)}`, { method: 'DELETE' });
showToast(`已清理 ${res?.deleted || 0} 条审计日志`);
loadAuditLogs(1);
} catch (err) { showToast('清理失败: ' + err.message, 'error'); }
}
// --- Backup Modal ---
async function showBackupModal() {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-database mr-2 text-green-400" > < / i > 数据库备份管理< / h3 >
< div class = "flex gap-3 mb-3" >
< button class = "btn btn-primary btn-sm" onclick = "createBackup()" > < i class = "fas fa-plus mr-1" > < / i > 创建新备份< / button >
< button class = "btn btn-sm" onclick = "loadBackupList()" > < i class = "fas fa-sync-alt mr-1" > < / i > 刷新< / button >
< / div >
< div id = "backupListContainer" style = "max-height:300px;overflow-y:auto" > < div class = "spinner" style = "margin:20px auto" > < / div > < / div >
< button class = "btn mt-3" onclick = "closeModal()" style = "width:100%" > 关闭< / button >
`, { maxWidth: '500px' });
loadBackupList();
}
async function loadBackupList() {
try {
const backups = await apiCall(`${API_BASE}/system/backups`);
const container = document.getElementById('backupListContainer');
if (!backups.length) { container.innerHTML = '< p class = "text-gray-500 text-center py-4" > 暂无备份文件< / p > '; return; }
container.innerHTML = backups.map(b => `
< div class = "flex items-center justify-between py-2 px-3 mb-1 rounded" style = "background:#1e293b" >
< div >
< span class = "text-sm font-medium" > ${b.filename}< / span >
< span class = "text-xs text-gray-500 ml-2" > ${b.size_mb} MB< / span >
< span class = "text-xs text-gray-500 ml-2" > ${new Date(b.created_at).toLocaleString('zh-CN')}< / span >
< / div >
< button class = "btn btn-sm" style = "padding:2px 8px;font-size:11px;color:#ef4444" onclick = "deleteBackup('${b.filename}')" > < i class = "fas fa-trash" > < / i > < / button >
< / div >
`).join('');
} catch (err) { document.getElementById('backupListContainer').innerHTML = '< p class = "text-red-400" > 加载失败: ' + err.message + '< / p > '; }
}
async function createBackup() {
showToast('正在创建备份...', 'info');
try {
const resp = await fetch(`${API_BASE}/system/backup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
const cd = resp.headers.get('content-disposition');
let filename = 'backup.db';
if (cd) { const m = cd.match(/filename="?(.+?)"?$/); if (m) filename = m[1]; }
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
showToast('备份已创建并开始下载');
loadBackupList();
} catch (err) { showToast('备份失败: ' + err.message, 'error'); }
}
async function deleteBackup(filename) {
if (!confirm(`确定删除备份 ${filename}? `)) return;
try {
await apiCall(`${API_BASE}/system/backups/${filename}`, { method: 'DELETE' });
showToast('备份已删除');
loadBackupList();
} catch (err) { showToast('删除失败: ' + err.message, 'error'); }
}
// --- Firmware Info Modal ---
async function showFirmwareModal() {
showModal(`
< h3 class = "text-lg font-semibold mb-4" > < i class = "fas fa-microchip mr-2 text-purple-400" > < / i > 设备固件信息< / h3 >
< div class = "flex gap-2 mb-3" >
< select id = "fwStatusFilter" onchange = "loadFirmwareList()" style = "padding:4px 8px;font-size:12px" >
< option value = "" > 全部设备< / option > < option value = "online" > 在线< / option > < option value = "offline" > 离线< / option >
< / select >
< button class = "btn btn-sm" style = "padding:4px 10px;font-size:12px" onclick = "batchQueryVersion()" > < i class = "fas fa-terminal mr-1" > < / i > 批量查询版本< / button >
< / div >
< div id = "fwListContainer" style = "max-height:400px;overflow-y:auto" > < div class = "spinner" style = "margin:20px auto" > < / div > < / div >
< button class = "btn mt-3" onclick = "closeModal()" style = "width:100%" > 关闭< / button >
`, { maxWidth: '650px' });
loadFirmwareList();
}
async function loadFirmwareList() {
const status = document.getElementById('fwStatusFilter')?.value || '';
let url = `${API_BASE}/system/firmware`;
if (status) url += `?status=${status}`;
try {
const devices = await apiCall(url);
const container = document.getElementById('fwListContainer');
if (!devices.length) { container.innerHTML = '< p class = "text-gray-500 text-center py-4" > 暂无设备< / p > '; return; }
container.innerHTML = '< table class = "w-full text-xs" > < thead > < tr > < th > IMEI< / th > < th > 名称< / th > < th > 型号< / th > < th > 状态< / th > < th > ICCID< / th > < th > IMSI< / th > < th > 最后登录< / th > < / tr > < / thead > < tbody > ' +
devices.map(d => `< tr >
< td style = "color:#60a5fa;font-family:monospace" > ${d.imei}< / td >
< td > ${d.name||'-'}< / td >
< td > < span style = "background:#1e3a5f;padding:1px 6px;border-radius:4px;font-size:10px" > ${d.device_type}< / span > < / td >
< td > < span style = "color:${d.status==='online'?'#22c55e':'#6b7280'}" > ${d.status==='online'?'在线':'离线'}< / span > < / td >
< td style = "font-size:10px;font-family:monospace" > ${d.iccid||'-'}< / td >
< td style = "font-size:10px;font-family:monospace" > ${d.imsi||'-'}< / td >
< td style = "white-space:nowrap" > ${d.last_login?new Date(d.last_login).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}):'-'}< / td >
< / tr > `).join('') + '< / tbody > < / table > ';
} catch (err) { document.getElementById('fwListContainer').innerHTML = '< p class = "text-red-400" > 加载失败: ' + err.message + '< / p > '; }
}
async function batchQueryVersion() {
if (!confirm('向所有在线设备发送 VERSION# 查询指令?')) return;
try {
const res = await apiCall(`${API_BASE}/commands/broadcast`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ command_type: 'online_cmd', command_content: 'VERSION#' }),
});
showToast(`已发送: ${res.sent}台, 未连接: ${res.failed}台`);
} catch (err) { showToast('发送失败: ' + err.message, 'error'); }
}
2026-03-27 10:19:34 +00:00
// ==================== INITIALIZATION ====================
document.addEventListener('DOMContentLoaded', () => {
navigateTo('dashboard');
});
< / script >
< / body >
< / html >