- 新增 POST /api/alarms/batch-delete、/api/bluetooth/batch-delete、 /api/heartbeats/batch-delete 批量删除端点 (最多500条) - 四个页面表格添加全选复选框和"删除选中"按钮 - 提取通用 toggleAllCheckboxes/updateSelCount/_batchDelete 函数 - 数据日志页面根据当前查询类型自动路由到对应的批量删除API via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
4107 lines
248 KiB
HTML
4107 lines
248 KiB
HTML
<!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>
|
||
<script src="https://webapi.amap.com/maps?v=2.0&key=9c2fe56bb2bad44d238dd9b4be249e33&plugin=AMap.MouseTool,AMap.PolygonEditor,AMap.CircleEditor"></script>
|
||
<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; }
|
||
.amap-container { z-index: 0 !important; }
|
||
.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; }
|
||
.amap-container { background: #111827; }
|
||
.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; }
|
||
.fence-tab { padding: 8px 16px; border: none; background: transparent; color: #9ca3af; cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent; transition: all 0.2s; }
|
||
.fence-tab:hover { color: #e5e7eb; background: #374151; }
|
||
.fence-tab.active { color: #60a5fa; border-bottom-color: #3b82f6; background: rgba(59,130,246,0.1); }
|
||
.panel-footer { padding: 6px 12px; border-top: 1px solid #374151; font-size: 11px; color: #6b7280; text-align: center; flex-shrink: 0; }
|
||
.panel-expand-btn { position: absolute; left: 0; top: 50%; transform: translateY(-50%); background: #1f2937; border: 1px solid #374151; border-left: none; border-radius: 0 6px 6px 0; padding: 8px 4px; color: #9ca3af; cursor: pointer; z-index: 5; display: none; }
|
||
.side-panel.collapsed ~ .page-main-content .panel-expand-btn { display: block; }
|
||
@media (max-width: 768px) { .page-with-panel { flex-direction: column; height: auto; } .side-panel { width: 100%; min-width: 100%; max-height: 300px; } .side-panel.collapsed { max-height: 0; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- 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>
|
||
<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>
|
||
<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>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||
<div class="stat-card">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<p class="text-gray-400 text-sm">设备总数</p>
|
||
<p class="text-3xl font-bold mt-1" id="dashTotalDevices">-</p>
|
||
</div>
|
||
<div class="w-12 h-12 bg-blue-900/50 rounded-xl flex items-center justify-center">
|
||
<i class="fas fa-microchip text-blue-400 text-xl"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<p class="text-gray-400 text-sm">在线设备</p>
|
||
<p class="text-3xl font-bold mt-1 text-green-400" id="dashOnlineDevices">-</p>
|
||
</div>
|
||
<div class="w-12 h-12 bg-green-900/50 rounded-xl flex items-center justify-center">
|
||
<i class="fas fa-wifi text-green-400 text-xl"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<p class="text-gray-400 text-sm">离线设备</p>
|
||
<p class="text-3xl font-bold mt-1 text-red-400" id="dashOfflineDevices">-</p>
|
||
</div>
|
||
<div class="w-12 h-12 bg-red-900/50 rounded-xl flex items-center justify-center">
|
||
<i class="fas fa-plug text-red-400 text-xl"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<p class="text-gray-400 text-sm">已连接设备</p>
|
||
<p class="text-3xl font-bold mt-1 text-purple-400" id="dashConnectedDevices">-</p>
|
||
</div>
|
||
<div class="w-12 h-12 bg-purple-900/50 rounded-xl flex items-center justify-center">
|
||
<i class="fas fa-link text-purple-400 text-xl"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||
<div class="stat-card">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<p class="text-gray-400 text-sm">告警总数</p>
|
||
<p class="text-3xl font-bold mt-1 text-yellow-400" id="dashTotalAlarms">-</p>
|
||
</div>
|
||
<div class="w-12 h-12 bg-yellow-900/50 rounded-xl flex items-center justify-center">
|
||
<i class="fas fa-bell text-yellow-400 text-xl"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<p class="text-gray-400 text-sm">未确认告警</p>
|
||
<p class="text-3xl font-bold mt-1 text-orange-400" id="dashUnackAlarms">-</p>
|
||
</div>
|
||
<div class="w-12 h-12 bg-orange-900/50 rounded-xl flex items-center justify-center">
|
||
<i class="fas fa-exclamation-triangle text-orange-400 text-xl"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<p class="text-gray-400 text-sm">已确认告警</p>
|
||
<p class="text-3xl font-bold mt-1 text-teal-400" id="dashAckAlarms">-</p>
|
||
</div>
|
||
<div class="w-12 h-12 bg-teal-900/50 rounded-xl flex items-center justify-center">
|
||
<i class="fas fa-check-circle text-teal-400 text-xl"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<p class="text-gray-400 text-sm">系统状态</p>
|
||
<p class="text-lg font-bold mt-1 text-green-400" id="dashSystemStatus">-</p>
|
||
</div>
|
||
<div class="w-12 h-12 bg-green-900/50 rounded-xl flex items-center justify-center">
|
||
<i class="fas fa-heartbeat text-green-400 text-xl"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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">
|
||
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-list mr-2 text-red-400"></i>最近告警</h3>
|
||
<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>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="showAddDeviceModal()"><i class="fas fa-plus"></i> 添加设备</button>
|
||
</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>
|
||
<th>IMEI</th>
|
||
<th>名称</th>
|
||
<th>类型</th>
|
||
<th>状态</th>
|
||
<th>电量</th>
|
||
<th>信号</th>
|
||
<th>最后登录</th>
|
||
<th>最后心跳</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="devicesTableBody">
|
||
<tr><td colspan="8" class="text-center text-gray-500 py-8">加载中...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div id="devicesPagination" class="pagination p-4"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== LOCATIONS PAGE ==================== -->
|
||
<div id="page-locations" 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>:发送WHERE#指令获取设备实时定位(需设备在线)</div></div>
|
||
<div class="guide-step"><div class="step-num">3</div><div class="step-text"><strong>轨迹回放</strong>:设定日期范围后点击"显示轨迹"查看移动路径</div></div>
|
||
<div class="guide-step"><div class="step-num">4</div><div class="step-text"><strong>定位类型</strong>:支持 GPS / LBS 基站 / WiFi 及其 4G 变体筛选</div></div>
|
||
</div>
|
||
<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">
|
||
<select id="locDeviceSelect" style="width:200px" onchange="onLocDeviceSelectChange(this.value)">
|
||
<option value="">全部设备</option>
|
||
</select>
|
||
<select id="locTypeFilter" style="width:150px">
|
||
<option value="">全部类型</option>
|
||
<option value="gps">GPS</option>
|
||
<option value="gps_4g">GPS 4G</option>
|
||
<option value="wifi">WiFi</option>
|
||
<option value="wifi_4g">WiFi 4G</option>
|
||
<option value="lbs">LBS</option>
|
||
<option value="lbs_4g">LBS 4G</option>
|
||
</select>
|
||
<input type="date" id="locStartDate" style="width:160px">
|
||
<input type="date" id="locEndDate" style="width:160px">
|
||
<button class="btn btn-primary" onclick="loadTrack()"><i class="fas fa-route"></i> 显示轨迹</button>
|
||
<button class="btn btn-primary" onclick="playTrack()" style="background:#7c3aed"><i class="fas fa-play"></i> 路径回放</button>
|
||
<button class="btn btn-success" onclick="loadLatestPosition()"><i class="fas fa-crosshairs"></i> 最新位置</button>
|
||
<button id="btnHideLowPrecision" class="btn btn-secondary" onclick="toggleHideLowPrecision()" title="隐藏 LBS/WiFi 低精度定位点,仅显示 GPS"><i class="fas fa-eye"></i> 低精度</button>
|
||
<button class="btn btn-secondary" onclick="loadLocationRecords()"><i class="fas fa-list"></i> 查询记录</button>
|
||
<button class="btn" style="background:#dc2626;color:#fff" onclick="batchDeleteNoCoordLocations()"><i class="fas fa-broom"></i> 清除无坐标</button>
|
||
<button class="btn" style="background:#b91c1c;color:#fff" id="btnBatchDeleteLoc" onclick="batchDeleteSelectedLocations()" disabled><i class="fas fa-trash-alt"></i> 删除选中 (<span id="locSelCount">0</span>)</button>
|
||
</div>
|
||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden mb-6" style="height: 500px; position: relative;">
|
||
<div id="locationMap" style="height: 100%; width: 100%;"></div>
|
||
<div id="mapLegend" style="display:none;position:absolute;bottom:10px;left:10px;background:rgba(30,30,40,0.85);border:1px solid #4b5563;border-radius:8px;padding:8px 12px;font-size:11px;color:#d1d5db;z-index:10;line-height:1.8">
|
||
<span style="color:#3b82f6">●</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>
|
||
</div>
|
||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
|
||
<div id="locationsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
|
||
<div class="overflow-x-auto">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th style="width:32px"><input type="checkbox" id="locSelectAll" onchange="toggleAllLocCheckboxes(this.checked)" title="全选"></th>
|
||
<th>设备ID</th>
|
||
<th>类型</th>
|
||
<th>纬度</th>
|
||
<th>经度</th>
|
||
<th>地址</th>
|
||
<th>速度</th>
|
||
<th>质量</th>
|
||
<th>时间</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="locationsTableBody">
|
||
<tr><td colspan="10" class="text-center text-gray-500 py-8">请选择设备并查询</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div id="locationsPagination" class="pagination p-4"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
</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>
|
||
<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>
|
||
</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>
|
||
<th style="width:32px"><input type="checkbox" id="alarmSelectAll" onchange="toggleAllCheckboxes('alarm-sel-cb','alarmSelCount','btnBatchDeleteAlarm',this.checked)" title="全选"></th>
|
||
<th>设备ID</th>
|
||
<th>类型</th>
|
||
<th>来源</th>
|
||
<th>位置</th>
|
||
<th>电量</th>
|
||
<th>信号</th>
|
||
<th>状态</th>
|
||
<th>时间</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="alarmsTableBody">
|
||
<tr><td colspan="10" class="text-center text-gray-500 py-8">加载中...</td></tr>
|
||
</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>
|
||
</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>
|
||
<select id="attSourceFilter" style="width:150px">
|
||
<option value="">全部来源</option>
|
||
<option value="device">设备打卡</option>
|
||
<option value="bluetooth">蓝牙打卡</option>
|
||
<option value="fence">围栏自动</option>
|
||
</select>
|
||
<input type="date" id="attStartDate" style="width:160px">
|
||
<input type="date" id="attEndDate" style="width:160px">
|
||
<button class="btn btn-primary" onclick="loadAttendance()"><i class="fas fa-search"></i> 查询</button>
|
||
<button class="btn btn-secondary" onclick="loadAttendance()"><i class="fas fa-sync-alt"></i> 刷新</button>
|
||
<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>
|
||
</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>
|
||
<th style="width:32px"><input type="checkbox" id="attSelectAll" onchange="toggleAllCheckboxes('att-sel-cb','attSelCount','btnBatchDeleteAtt',this.checked)" title="全选"></th>
|
||
<th>设备ID</th>
|
||
<th>类型</th>
|
||
<th>来源</th>
|
||
<th>位置</th>
|
||
<th>电量/信号</th>
|
||
<th>基站</th>
|
||
<th>时间</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="attendanceTableBody">
|
||
<tr><td colspan="9" class="text-center text-gray-500 py-8">加载中...</td></tr>
|
||
</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>
|
||
<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>
|
||
<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>
|
||
</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>
|
||
<th style="width:32px"><input type="checkbox" id="btSelectAll" onchange="toggleAllCheckboxes('bt-sel-cb','btSelCount','btnBatchDeleteBt',this.checked)" title="全选"></th>
|
||
<th>设备ID</th>
|
||
<th>类型</th>
|
||
<th>信标MAC</th>
|
||
<th>UUID / Major / Minor</th>
|
||
<th>RSSI</th>
|
||
<th>信标电量</th>
|
||
<th>打卡</th>
|
||
<th>时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="bluetoothTableBody">
|
||
<tr><td colspan="9" class="text-center text-gray-500 py-8">加载中...</td></tr>
|
||
</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 -->
|
||
<div class="page-main-content" style="position:relative">
|
||
<button class="panel-expand-btn" onclick="toggleSidePanel('beaconSidePanel')" title="展开信标面板"><i class="fas fa-chevron-right"></i></button>
|
||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||
<input type="text" id="beaconSearch" placeholder="搜索 MAC / 名称 / 区域" style="width:220px">
|
||
<select id="beaconStatusFilter" style="width:150px">
|
||
<option value="">全部状态</option>
|
||
<option value="active">启用</option>
|
||
<option value="inactive">停用</option>
|
||
</select>
|
||
<button class="btn btn-primary" onclick="loadBeacons()"><i class="fas fa-search"></i> 查询</button>
|
||
<button class="btn btn-secondary" onclick="loadBeacons()"><i class="fas fa-sync-alt"></i> 刷新</button>
|
||
<div style="flex:1"></div>
|
||
<button class="btn btn-primary" onclick="showAddBeaconModal()"><i class="fas fa-plus"></i> 添加信标</button>
|
||
</div>
|
||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
|
||
<div id="beaconsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
|
||
<div class="overflow-x-auto">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>MAC 地址</th>
|
||
<th>名称</th>
|
||
<th>UUID / Major / Minor</th>
|
||
<th>楼层 / 区域</th>
|
||
<th>坐标</th>
|
||
<th>状态</th>
|
||
<th>更新时间</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="beaconsTableBody">
|
||
<tr><td colspan="8" class="text-center text-gray-500 py-8">加载中...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div id="beaconsPagination" class="pagination p-4"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 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>
|
||
<!-- Top Tabs -->
|
||
<div style="display:flex;border-bottom:1px solid #374151;background:#1f2937;border-radius:8px 8px 0 0;margin-bottom:12px">
|
||
<button id="fenceTabList" class="fence-tab active" onclick="switchFenceTab('list')"><i class="fas fa-map-marked-alt"></i> 围栏管理</button>
|
||
<button id="fenceTabBindings" class="fence-tab" onclick="switchFenceTab('bindings')"><i class="fas fa-link"></i> 设备绑定</button>
|
||
</div>
|
||
<!-- Tab Content: Fence List + Map -->
|
||
<div id="fenceTabContentList" style="display:flex;flex-direction:column;flex:1;overflow:hidden">
|
||
<div class="flex flex-wrap items-center gap-3 mb-3">
|
||
<button class="btn btn-primary" onclick="loadFences()"><i class="fas fa-sync-alt"></i> 刷新</button>
|
||
<div style="flex:1;display:flex;gap:6px;max-width:400px">
|
||
<input type="text" id="fenceMapSearchInput" placeholder="搜索地点..." style="flex:1" onkeydown="if(event.key==='Enter')searchFenceMapLocation()">
|
||
<button class="btn btn-secondary" onclick="searchFenceMapLocation()"><i class="fas fa-search"></i></button>
|
||
</div>
|
||
<div style="flex:1"></div>
|
||
<button class="btn btn-primary" onclick="showAddFenceModal()"><i class="fas fa-plus"></i> 添加围栏</button>
|
||
</div>
|
||
<div id="fenceMapContainer" style="flex:1;min-height:400px;border-radius:12px;border:1px solid #374151;margin-bottom:12px;position:relative;">
|
||
<div id="fenceMap" style="height:100%;width:100%;border-radius:12px;"></div>
|
||
</div>
|
||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" style="max-height:300px;overflow-y:auto">
|
||
<div id="fencesLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<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>
|
||
</div>
|
||
<!-- Tab Content: Device Bindings -->
|
||
<div id="fenceTabContentBindings" style="display:none;flex-direction:column;flex:1;overflow:hidden">
|
||
<div class="flex flex-wrap items-center gap-3 mb-3">
|
||
<button class="btn btn-primary" onclick="loadBindingMatrix()"><i class="fas fa-sync-alt"></i> 刷新</button>
|
||
<span style="color:#9ca3af;font-size:12px"><i class="fas fa-info-circle"></i> 勾选表示绑定设备到围栏,取消勾选自动解绑</span>
|
||
<div style="flex:1"></div>
|
||
<button id="fenceBindSaveBtn" class="btn btn-primary" onclick="saveBindingMatrix()"><i class="fas fa-save"></i> 保存更改</button>
|
||
</div>
|
||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" style="flex:1;overflow:auto">
|
||
<div id="fenceBindMatrixWrap" class="overflow-x-auto" style="height:100%;overflow-y:auto">
|
||
<table id="fenceBindMatrix" style="font-size:12px">
|
||
<thead id="fenceBindMatrixHead" style="position:sticky;top:0;background:#1f2937;z-index:1"></thead>
|
||
<tbody id="fenceBindMatrixBody">
|
||
<tr><td class="text-center text-gray-500 py-4">加载中...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
<option value="location">位置</option>
|
||
<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>
|
||
<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>
|
||
</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>
|
||
<th style="width:32px"><input type="checkbox" id="logSelectAll" onchange="toggleAllCheckboxes('log-sel-cb','logSelCount','btnBatchDeleteLog',this.checked)" title="全选"></th>
|
||
<th>ID</th>
|
||
<th>类型</th>
|
||
<th>设备ID</th>
|
||
<th>IMEI</th>
|
||
<th>详情</th>
|
||
<th>坐标</th>
|
||
<th>地址</th>
|
||
<th>时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="datalogTableBody">
|
||
<tr><td colspan="9" class="text-center text-gray-500 py-8">选择筛选条件后点击查询</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div id="datalogPagination" class="pagination p-4"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 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>
|
||
<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>
|
||
<select id="cmdDeviceSelect">
|
||
<option value="">选择设备...</option>
|
||
</select>
|
||
</div>
|
||
<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>
|
||
<select id="msgDeviceSelect">
|
||
<option value="">选择设备...</option>
|
||
</select>
|
||
</div>
|
||
<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>
|
||
<select id="ttsDeviceSelect">
|
||
<option value="">选择设备...</option>
|
||
</select>
|
||
</div>
|
||
<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>
|
||
|
||
<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;
|
||
let locationMap = null;
|
||
let mapMarkers = [];
|
||
let mapPolyline = null;
|
||
let mapInfoWindows = []; // store {infoWindow, position} for each track point
|
||
let _locTableItems = []; // cached location records from table for on-the-fly marker creation
|
||
let trackPlayTimer = null;
|
||
let trackMovingMarker = null;
|
||
let dashAlarmChart = null;
|
||
let alarmTypeChart = null;
|
||
|
||
// Side panel state
|
||
let panelDevices = [];
|
||
let panelBeacons = [];
|
||
let panelFences = [];
|
||
let selectedPanelDeviceId = null;
|
||
let selectedPanelBeaconId = null;
|
||
let selectedPanelFenceId = null;
|
||
|
||
// 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: '拆除',
|
||
door: '门', shutdown: '关机',
|
||
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();
|
||
if (typeof destroyFenceDrawMap === 'function') destroyFenceDrawMap();
|
||
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: '位置追踪',
|
||
alarms: '告警管理', attendance: '考勤记录', bluetooth: '蓝牙记录', beacons: '信标管理', fences: '围栏管理', datalog: '数据日志', commands: '指令管理'
|
||
};
|
||
|
||
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 = []; }
|
||
if (page !== 'fences') { selectedPanelFenceId = null; panelFences = []; }
|
||
|
||
switch (page) {
|
||
case 'dashboard': loadDashboard(); dashboardInterval = setInterval(loadDashboard, 30000); break;
|
||
case 'devices': loadDevices(); break;
|
||
case 'locations': initLocationMap(); loadDeviceSelectors(); break;
|
||
case 'alarms': loadAlarmStats(); loadAlarms(); loadDeviceSelectors(); break;
|
||
case 'attendance': loadAttendanceStats(); loadAttendance(); loadDeviceSelectors(); break;
|
||
case 'bluetooth': loadBluetooth(); loadDeviceSelectors(); break;
|
||
case 'beacons': loadBeacons(); break;
|
||
case 'fences': initFenceMap(); loadFences(); break;
|
||
case 'datalog': loadDataLogStats(); loadDataLog(); loadDeviceSelectors(); break;
|
||
case 'commands': loadCommands(); loadDeviceSelectors(); break;
|
||
}
|
||
}
|
||
|
||
// ==================== 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' },
|
||
fences: { panel: 'fenceSidePanel', list: 'fencePanelList', count: 'fencePanelCount', footer: 'fencePanelFooter', search: 'fencePanelSearch' },
|
||
};
|
||
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');
|
||
// Resize map when panel toggled
|
||
if (currentPage === 'locations' && locationMap) {
|
||
setTimeout(() => { if (locationMap) locationMap.resize(); }, PANEL_TRANSITION_MS + 50);
|
||
}
|
||
if (currentPage === 'fences' && fenceMap) {
|
||
setTimeout(() => { if (fenceMap) fenceMap.resize(); }, PANEL_TRANSITION_MS + 50);
|
||
}
|
||
}
|
||
|
||
function formatTimeAgo(t) {
|
||
if (!t) return '-';
|
||
try {
|
||
const diff = Math.floor((Date.now() - new Date(t).getTime()) / 1000);
|
||
if (isNaN(diff) || diff < 0) return '-';
|
||
if (diff < 60) return '刚刚';
|
||
if (diff < 3600) return Math.floor(diff / 60) + '分钟前';
|
||
if (diff < 86400) return Math.floor(diff / 3600) + '小时前';
|
||
if (diff < 2592000) return Math.floor(diff / 86400) + '天前';
|
||
return formatTime(t);
|
||
} catch { return '-'; }
|
||
}
|
||
|
||
/** Generic panel render helper — sets count & footer text, returns container or null */
|
||
function _initPanelRender(ids, items, statusField, statusValue, emptyText, countFmt, footerFmt) {
|
||
const container = document.getElementById(ids.list);
|
||
const countEl = document.getElementById(ids.count);
|
||
const footerEl = document.getElementById(ids.footer);
|
||
if (!container) return null;
|
||
const activeCount = items.filter(it => it[statusField] === statusValue).length;
|
||
if (countEl) countEl.textContent = countFmt(activeCount, items.length);
|
||
if (footerEl) footerEl.textContent = footerFmt(activeCount, items.length);
|
||
if (items.length === 0) {
|
||
container.innerHTML = `<div style="text-align:center;padding:20px;color:#6b7280;font-size:13px">${emptyText}</div>`;
|
||
return null;
|
||
}
|
||
return container;
|
||
}
|
||
|
||
function renderDevicePanel(devices) {
|
||
panelDevices = devices;
|
||
const container = _initPanelRender(
|
||
PANEL_IDS.locations, devices, 'status', 'online', '暂无设备',
|
||
(a, t) => `${a}/${t}`, (a, t) => `共 ${t} 台设备,${a} 台在线`
|
||
);
|
||
if (!container) return;
|
||
container.innerHTML = devices.map(d => {
|
||
const isActive = (d.id || d.device_id) == selectedPanelDeviceId;
|
||
const statusClass = d.status === 'online' ? 'online' : 'offline';
|
||
const deviceId = d.id || d.device_id || '';
|
||
const imeiShort = d.imei ? '...' + d.imei.slice(-8) : '-';
|
||
const bp = d.battery_level;
|
||
const bColor = bp != null ? (bp < 20 ? '#f87171' : bp < 50 ? '#fbbf24' : '#34d399') : '#4b5563';
|
||
const lastActive = d.last_heartbeat || d.last_login;
|
||
const timeAgo = lastActive ? formatTimeAgo(lastActive) : '无活动';
|
||
return `<div class="panel-item ${isActive ? 'active' : ''}" data-device-id="${deviceId}" data-search-text="${(d.name||'').toLowerCase()} ${(d.imei||'').toLowerCase()}" onclick="selectPanelDevice('${deviceId}')">
|
||
<div class="panel-item-actions">
|
||
<button class="panel-action-btn" onclick="event.stopPropagation();selectPanelDevice('${deviceId}')" title="定位"><i class="fas fa-crosshairs"></i></button>
|
||
<button class="panel-action-btn" onclick="event.stopPropagation();showDeviceDetail('${deviceId}')" title="详情"><i class="fas fa-info-circle"></i></button>
|
||
</div>
|
||
<div class="panel-item-header">
|
||
<span class="panel-item-name">${escapeHtml(d.name || d.imei || deviceId)}</span>
|
||
<div class="panel-item-status ${statusClass}"></div>
|
||
</div>
|
||
<div class="panel-item-sub">${escapeHtml(imeiShort)}</div>
|
||
<div class="panel-item-meta">
|
||
${bp != null ? `<span style="display:flex;align-items:center;gap:3px"><span class="battery-bar"><span class="battery-bar-fill" style="width:${bp}%;background:${bColor}"></span></span><span>${bp}%</span></span>` : ''}
|
||
${d.gsm_signal != null ? `<span><i class="fas fa-signal" style="font-size:10px"></i> ${d.gsm_signal}</span>` : ''}
|
||
<span style="margin-left:auto"><i class="far fa-clock" style="font-size:10px"></i> ${timeAgo}</span>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderBeaconPanel(beacons) {
|
||
panelBeacons = beacons;
|
||
const container = _initPanelRender(
|
||
PANEL_IDS.beacons, beacons, 'status', 'active', '暂无信标',
|
||
(a, t) => `${a}/${t}`, (a, t) => `共 ${t} 个信标,${a} 个启用`
|
||
);
|
||
if (!container) return;
|
||
container.innerHTML = beacons.map(b => {
|
||
const isActive = b.id == selectedPanelBeaconId;
|
||
const statusClass = b.status === 'active' ? 'active' : 'inactive';
|
||
const floorArea = [b.floor, b.area].filter(Boolean).join(' / ') || '未设置';
|
||
const macShort = b.beacon_mac ? b.beacon_mac.slice(-8) : '-';
|
||
return `<div class="panel-item ${isActive ? 'active' : ''}" data-beacon-id="${b.id}" data-search-text="${(b.name||'').toLowerCase()} ${(b.beacon_mac||'').toLowerCase()} ${(b.area||'').toLowerCase()}" onclick="selectPanelBeacon(${b.id})">
|
||
<div class="panel-item-actions">
|
||
<button class="panel-action-btn" onclick="event.stopPropagation();showEditBeaconModal(${b.id})" title="编辑"><i class="fas fa-edit"></i></button>
|
||
</div>
|
||
<div class="panel-item-header">
|
||
<span class="panel-item-name">${escapeHtml(b.name || b.beacon_mac)}</span>
|
||
<div class="panel-item-status ${statusClass}"></div>
|
||
</div>
|
||
<div class="panel-item-sub">${escapeHtml(macShort)}</div>
|
||
<div class="panel-item-meta">
|
||
<span><i class="fas fa-layer-group" style="font-size:10px"></i> ${escapeHtml(floorArea)}</span>
|
||
${b.latitude && b.longitude ? '<span style="color:#34d399"><i class="fas fa-map-pin" style="font-size:10px"></i> 已定位</span>' : '<span style="color:#6b7280">未定位</span>'}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function filterPanelItems(type) {
|
||
const ids = PANEL_IDS[type];
|
||
if (!ids) return;
|
||
const inputEl = document.getElementById(ids.search);
|
||
const listEl = document.getElementById(ids.list);
|
||
if (!inputEl || !listEl) return;
|
||
const keyword = (inputEl.value || '').toLowerCase().trim();
|
||
listEl.querySelectorAll('.panel-item').forEach(item => {
|
||
item.style.display = (!keyword || (item.dataset.searchText || '').includes(keyword)) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function selectPanelDevice(deviceId, autoLocate = true) {
|
||
selectedPanelDeviceId = deviceId;
|
||
document.querySelectorAll('#locPanelList .panel-item').forEach(el => {
|
||
el.classList.toggle('active', el.dataset.deviceId == deviceId);
|
||
});
|
||
const select = document.getElementById('locDeviceSelect');
|
||
if (select) select.value = deviceId;
|
||
const activeCard = document.querySelector(`#locPanelList .panel-item[data-device-id="${deviceId}"]`);
|
||
if (activeCard) activeCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
// Reload location records filtered by selected device
|
||
loadLocationRecords(1);
|
||
}
|
||
|
||
function onLocDeviceSelectChange(deviceId) {
|
||
selectedPanelDeviceId = deviceId;
|
||
document.querySelectorAll('#locPanelList .panel-item').forEach(el => {
|
||
el.classList.toggle('active', el.dataset.deviceId == deviceId);
|
||
});
|
||
loadLocationRecords(1);
|
||
}
|
||
|
||
function selectPanelBeacon(beaconId) {
|
||
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;
|
||
|
||
async function loadDeviceSelectors() {
|
||
try {
|
||
const data = await apiCall(`${API_BASE}/devices?page=1&page_size=100`);
|
||
const devices = data.items || [];
|
||
cachedDevices = devices;
|
||
const selectors = ['locDeviceSelect', 'alarmDeviceFilter', 'attDeviceFilter', 'btDeviceFilter', 'cmdDeviceSelect', 'msgDeviceSelect', 'ttsDeviceSelect', 'cmdHistoryDeviceFilter'];
|
||
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;
|
||
});
|
||
// Render device panel on locations page
|
||
if (currentPage === 'locations' && document.getElementById('locPanelList')) {
|
||
const sorted = sortDevicesByActivity(devices);
|
||
renderDevicePanel(sorted);
|
||
if (!selectedPanelDeviceId) {
|
||
autoSelectActiveDevice(sorted); // this calls selectPanelDevice → loadLocationRecords
|
||
}
|
||
// If no devices or already selected, ensure records are loaded
|
||
if (!sorted.length || selectedPanelDeviceId) loadLocationRecords(1);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load device selectors:', err);
|
||
}
|
||
}
|
||
|
||
// ==================== DASHBOARD ====================
|
||
async function loadDashboard() {
|
||
try {
|
||
const [deviceStats, alarmStats, health] = await Promise.allSettled([
|
||
apiCall(`${API_BASE}/devices/stats`),
|
||
apiCall(`${API_BASE}/alarms/stats`),
|
||
apiCall('/health'),
|
||
]);
|
||
|
||
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);
|
||
animateCounter('dashAckAlarms', as.acknowledged || 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;
|
||
animateCounter('dashConnectedDevices', connected);
|
||
document.getElementById('dashSystemStatus').textContent = status === 'ok' || status === 'healthy' ? '正常运行' : status;
|
||
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 = '无法连接';
|
||
}
|
||
|
||
// 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>
|
||
<span class="text-xs text-gray-500 ml-2">设备: ${escapeHtml(a.device_id || '-')}</span>
|
||
</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 = [];
|
||
const colorMap = { sos: '#ef4444', low_battery: '#f97316', remove: '#eab308', fence: '#a855f7', fall: '#3b82f6' };
|
||
const nameMap = { sos: 'SOS', low_battery: '低电量', remove: '拆除', fence: '围栏', fall: '跌倒' };
|
||
|
||
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 ====================
|
||
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);
|
||
const items = data.items || [];
|
||
const tbody = document.getElementById('devicesTableBody');
|
||
|
||
if (items.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-500 py-8">没有找到设备</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = items.map(d => `
|
||
<tr style="cursor:pointer" onclick="showDeviceDetail('${d.id || d.device_id || ''}')">
|
||
<td class="font-mono text-sm">${escapeHtml(d.imei)}</td>
|
||
<td>${escapeHtml(d.name || '-')}</td>
|
||
<td>${escapeHtml(d.device_type || '-')}</td>
|
||
<td>${statusBadge(d.status)}</td>
|
||
<td>${d.battery_level !== undefined && d.battery_level !== null ? `<span class="${d.battery_level < 20 ? 'text-red-400' : 'text-green-400'}">${d.battery_level}%</span>` : '-'}</td>
|
||
<td>${d.gsm_signal !== undefined && d.gsm_signal !== null ? d.gsm_signal : '-'}</td>
|
||
<td class="text-xs text-gray-400">${formatTime(d.last_login)}</td>
|
||
<td class="text-xs text-gray-400">${formatTime(d.last_heartbeat)}</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
renderPagination('devicesPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadDevices');
|
||
} catch (err) {
|
||
showToast('加载设备列表失败: ' + err.message, 'error');
|
||
document.getElementById('devicesTableBody').innerHTML = '<tr><td colspan="8" class="text-center text-red-400 py-8">加载失败</td></tr>';
|
||
} 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 || '-';
|
||
}
|
||
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('');
|
||
}
|
||
|
||
// --- 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');
|
||
locationMap.setCenter(positions[0]);
|
||
locationMap.setZoom(16);
|
||
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) ---
|
||
let _focusInfoWindow = null; // single info window for table-click focus
|
||
let _focusMarker = null; // single marker for table-click focus
|
||
function focusMapPoint(locId) {
|
||
if (!locationMap) initLocationMap();
|
||
// Wait for map init
|
||
if (!locationMap) { showToast('地图初始化中,请稍后重试', 'info'); return; }
|
||
|
||
// First try existing track markers
|
||
const existing = mapInfoWindows.find(iw => iw.locId === locId);
|
||
if (existing) {
|
||
locationMap.setCenter(existing.position);
|
||
locationMap.setZoom(17);
|
||
mapInfoWindows.forEach(iw => iw.infoWindow.close());
|
||
if (_focusInfoWindow) _focusInfoWindow.close();
|
||
if (_focusMarker) { locationMap.remove(_focusMarker); _focusMarker = null; }
|
||
existing.infoWindow.open(locationMap, existing.position);
|
||
document.getElementById('locationMap').scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
return;
|
||
}
|
||
|
||
// Fallback: create marker on-the-fly from cached table data
|
||
const loc = _locTableItems.find(l => l.id === locId);
|
||
if (!loc) { showToast('记录数据不可用', 'info'); return; }
|
||
const lat = loc.latitude || loc.lat;
|
||
const lng = loc.longitude || loc.lng || loc.lon;
|
||
if (!lat || !lng) { showToast('该记录无坐标', 'info'); return; }
|
||
|
||
const [mLat, mLng] = toMapCoord(lat, lng);
|
||
|
||
// Remove previous focus marker
|
||
if (_focusMarker) { locationMap.remove(_focusMarker); _focusMarker = null; }
|
||
if (_focusInfoWindow) _focusInfoWindow.close();
|
||
|
||
// Create marker
|
||
_focusMarker = new AMap.Marker({ position: [mLng, mLat] });
|
||
_focusMarker.setMap(locationMap);
|
||
|
||
// Create and open info window
|
||
const content = _buildInfoContent('位置记录', loc, lat, lng);
|
||
_focusInfoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -30) });
|
||
_focusInfoWindow.open(locationMap, [mLng, mLat]);
|
||
_focusMarker.on('click', () => _focusInfoWindow.open(locationMap, [mLng, mLat]));
|
||
|
||
locationMap.setCenter([mLng, mLat]);
|
||
locationMap.setZoom(17);
|
||
document.getElementById('locationMap').scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
|
||
// --- 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');
|
||
}
|
||
}
|
||
|
||
// ==================== GENERIC BATCH DELETE HELPERS ====================
|
||
function toggleAllCheckboxes(cbClass, countSpanId, btnId, checked) {
|
||
document.querySelectorAll('.' + cbClass).forEach(cb => { cb.checked = checked; });
|
||
updateSelCount(cbClass, countSpanId, btnId);
|
||
}
|
||
function updateSelCount(cbClass, countSpanId, btnId) {
|
||
const count = document.querySelectorAll('.' + cbClass + ':checked').length;
|
||
document.getElementById(countSpanId).textContent = count;
|
||
document.getElementById(btnId).disabled = count === 0;
|
||
}
|
||
// Location (compat wrappers)
|
||
function toggleAllLocCheckboxes(checked) { toggleAllCheckboxes('loc-sel-cb','locSelCount','btnBatchDeleteLoc', checked); }
|
||
function updateLocSelCount() { updateSelCount('loc-sel-cb','locSelCount','btnBatchDeleteLoc'); }
|
||
|
||
async function _batchDelete(cbClass, apiPath, idKey, label, reloadFn) {
|
||
const ids = Array.from(document.querySelectorAll('.' + cbClass + ':checked')).map(cb => parseInt(cb.value));
|
||
if (ids.length === 0) { showToast('请先勾选要删除的记录', 'info'); return; }
|
||
if (!confirm(`确定批量删除选中的 ${ids.length} 条${label}?`)) return;
|
||
try {
|
||
const result = await apiCall(`${API_BASE}/${apiPath}`, {
|
||
method: 'POST', body: JSON.stringify({ [idKey]: ids }),
|
||
});
|
||
showToast(`已删除 ${result.deleted} 条记录`);
|
||
reloadFn();
|
||
} catch (err) {
|
||
showToast('批量删除失败: ' + err.message, 'error');
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
|
||
async function batchDeleteNoCoordLocations() {
|
||
const deviceId = document.getElementById('locDeviceSelect').value || null;
|
||
const startTime = document.getElementById('locStartDate').value || null;
|
||
const endTime = document.getElementById('locEndDate').value || null;
|
||
const filterDesc = [
|
||
deviceId ? `设备ID=${deviceId}` : '所有设备',
|
||
startTime ? `从${startTime}` : '',
|
||
endTime ? `到${endTime}` : '',
|
||
].filter(Boolean).join(', ');
|
||
if (!confirm(`确定删除无坐标(经纬度为空)的位置记录?\n范围: ${filterDesc}\n\n此操作不可撤销!`)) return;
|
||
try {
|
||
const body = {};
|
||
if (deviceId) body.device_id = parseInt(deviceId);
|
||
if (startTime) body.start_time = startTime + 'T00:00:00';
|
||
if (endTime) body.end_time = endTime + 'T23:59:59';
|
||
const result = await apiCall(`${API_BASE}/locations/delete-no-coords`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(body),
|
||
});
|
||
showToast(`已清除 ${result.deleted} 条无坐标记录`);
|
||
loadLocationRecords();
|
||
} catch (err) {
|
||
showToast('清除失败: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// --- Quick command sender for device detail panel ---
|
||
async function _quickCmd(deviceId, cmd, btnEl) {
|
||
if (btnEl) { btnEl.disabled = true; btnEl.style.opacity = '0.5'; }
|
||
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>
|
||
<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>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">
|
||
${_btn('bluetooth-b', '蓝牙 开', 'BTON#', '#2563eb')}
|
||
${_btn('bluetooth-b', '蓝牙 关', 'BTOFF#', '#991b1b')}
|
||
${_btn('broadcast-tower', 'BLE扫描 开', 'BTSCAN,1#', '#7c3aed')}
|
||
${_btn('broadcast-tower', 'BLE扫描 关', 'BTSCAN,0#', '#991b1b')}
|
||
</div>
|
||
<div style="font-size:11px;color:#6b7280;margin-bottom:6px">工作模式:</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">
|
||
${_btn('clock', '定时定位', 'MODE,1#', '#4b5563')}
|
||
${_btn('brain', '智能模式', 'MODE,3#', '#4b5563')}
|
||
</div>
|
||
<div style="font-size:11px;color:#6b7280;margin-bottom:6px">上报间隔:</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px;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>
|
||
<div style="font-size:11px;color:#6b7280;margin-bottom:6px">信息查询:</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px">
|
||
${_btn('info-circle', '设备状态', 'STATUS#', '#374151')}
|
||
${_btn('cog', '参数查询', 'PARAM#', '#374151')}
|
||
${_btn('code-branch', '固件版本', 'VERSION#', '#374151')}
|
||
${_btn('stopwatch', '定时器查询', 'TIMER#', '#374151')}
|
||
${_btn('clipboard-check', '完整信息', 'CHECK#', '#374151')}
|
||
</div>
|
||
<div id="detailCmdResult" style="margin-top:10px;padding:8px;background:#0d1117;border-radius:6px;min-height:20px;font-family:monospace;word-break:break-all;display:${online?'block':'none'}"></div>
|
||
</div>
|
||
|
||
<!-- TTS -->
|
||
<div style="margin-top:14px;padding:12px;background:#111827;border-radius:8px;border:1px solid #374151${online?'':'display:none'}">
|
||
<div style="font-size:13px;font-weight:600;color:#9ca3af;margin-bottom:8px"><i class="fas fa-volume-up mr-1 text-purple-400"></i>语音播报 (TTS)</div>
|
||
<div style="display:flex;gap:8px">
|
||
<input id="detailTtsInput" type="text" placeholder="输入语音内容 (最多200字)" maxlength="200" style="flex:1;background:#1f2937;border:1px solid #374151;border-radius:6px;padding:6px 10px;color:#e5e7eb;font-size:13px" ${online?'':'disabled'}>
|
||
<button class="btn" style="font-size:12px;padding:6px 14px;background:#7c3aed;border:none;color:#fff;border-radius:6px;white-space:nowrap" ${disabledAttr} onclick="_quickTts('${did}')"><i class="fas fa-play"></i> 播报</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 危险操作 -->
|
||
<div style="margin-top:14px;padding:12px;background:rgba(127,29,29,0.15);border-radius:8px;border:1px solid #7f1d1d${online?'':'display:none'}">
|
||
<div style="font-size:13px;font-weight:600;color:#fca5a5;margin-bottom:8px"><i class="fas fa-exclamation-triangle mr-1"></i>系统操作</div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||
<button class="btn" style="font-size:12px;padding:5px 10px;background:#991b1b;border:none;color:#fff;border-radius:6px" ${disabledAttr} onclick="if(confirm('确定重启设备?'))_quickCmd('${did}','RESET#',this)"><i class="fas fa-power-off"></i> 重启设备</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-3 mt-5">
|
||
<button class="btn btn-primary flex-1" onclick="showEditDeviceModal('${did}')"><i class="fas fa-edit"></i> 编辑</button>
|
||
<button class="btn btn-danger flex-1" onclick="confirmDeleteDevice('${did}', '${escapeHtml(d.name || d.imei)}')"><i class="fas fa-trash"></i> 删除</button>
|
||
<button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 关闭</button>
|
||
</div>
|
||
`);
|
||
} 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');
|
||
}
|
||
}
|
||
|
||
// ==================== MAP COORDINATE CONVERSION ====================
|
||
// WGS-84 → GCJ-02 (高德 JS API uses GCJ-02 natively)
|
||
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];
|
||
}
|
||
|
||
// Convert WGS-84 coords to GCJ-02 for 高德地图
|
||
function toMapCoord(lat, lng) {
|
||
return wgs84ToGcj02(lat, lng);
|
||
}
|
||
|
||
// ==================== LOCATIONS ====================
|
||
function initLocationMap() {
|
||
if (locationMap) return;
|
||
setTimeout(() => {
|
||
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',
|
||
});
|
||
}, 100);
|
||
}
|
||
|
||
function clearMapOverlays() {
|
||
mapMarkers.forEach(m => { if (!m._lpHidden) locationMap.remove(m); });
|
||
mapMarkers = [];
|
||
mapInfoWindows = [];
|
||
if (mapPolyline) { locationMap.remove(mapPolyline); mapPolyline = null; }
|
||
_trackPolyline = null;
|
||
_trackLocations = null;
|
||
if (trackPlayTimer) { cancelAnimationFrame(trackPlayTimer); trackPlayTimer = null; }
|
||
if (trackMovingMarker) { locationMap.remove(trackMovingMarker); trackMovingMarker = null; }
|
||
if (_focusInfoWindow) { _focusInfoWindow.close(); _focusInfoWindow = null; }
|
||
if (_focusMarker) { locationMap.remove(_focusMarker); _focusMarker = null; }
|
||
const legend = document.getElementById('mapLegend');
|
||
if (legend) legend.style.display = 'none';
|
||
}
|
||
|
||
// ---- Hide low-precision toggle ----
|
||
let _hideLowPrecision = false;
|
||
function toggleHideLowPrecision() {
|
||
_hideLowPrecision = !_hideLowPrecision;
|
||
const btn = document.getElementById('btnHideLowPrecision');
|
||
if (_hideLowPrecision) {
|
||
btn.style.background = '#b91c1c';
|
||
btn.style.color = '#fff';
|
||
btn.innerHTML = '<i class="fas fa-eye-slash"></i> 低精度';
|
||
} else {
|
||
btn.style.background = '';
|
||
btn.style.color = '';
|
||
btn.innerHTML = '<i class="fas fa-eye"></i> 低精度';
|
||
}
|
||
// Re-apply to existing track markers
|
||
_applyLowPrecisionFilter();
|
||
}
|
||
function _isLowPrecision(locationType) {
|
||
const t = (locationType || '').toLowerCase();
|
||
return t.startsWith('lbs') || t.startsWith('wifi');
|
||
}
|
||
function _applyLowPrecisionFilter() {
|
||
// Toggle visibility of low-precision markers stored with _lpFlag
|
||
mapMarkers.forEach(m => {
|
||
if (m._lpFlag) {
|
||
if (_hideLowPrecision) { m.hide ? m.hide() : m.setMap(null); m._lpHidden = true; }
|
||
else { m.show ? m.show() : m.setMap(locationMap); m._lpHidden = false; }
|
||
}
|
||
});
|
||
// Also filter the track polyline if exists — rebuild path without LP points
|
||
if (_trackPolyline && _trackLocations) {
|
||
const path = [];
|
||
_trackLocations.forEach(loc => {
|
||
if (_hideLowPrecision && _isLowPrecision(loc.location_type)) return;
|
||
const lat = loc.latitude || loc.lat;
|
||
const lng = loc.longitude || loc.lng || loc.lon;
|
||
if (lat && lng) {
|
||
const [mLat, mLng] = toMapCoord(lat, lng);
|
||
path.push([mLng, mLat]);
|
||
}
|
||
});
|
||
_trackPolyline.setPath(path);
|
||
}
|
||
// Filter table rows
|
||
document.querySelectorAll('#locationsTableBody tr[data-loc-type]').forEach(tr => {
|
||
if (_hideLowPrecision && _isLowPrecision(tr.dataset.locType)) {
|
||
tr.style.display = 'none';
|
||
} else {
|
||
tr.style.display = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
let _trackPolyline = null;
|
||
let _trackLocations = null;
|
||
|
||
async function loadTrack() {
|
||
const deviceId = document.getElementById('locDeviceSelect').value;
|
||
if (!deviceId) { showToast('请选择设备', 'error'); return; }
|
||
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;
|
||
}
|
||
|
||
const path = [];
|
||
const total = locations.length;
|
||
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);
|
||
path.push([mLng, mLat]);
|
||
const isFirst = i === 0;
|
||
const isLast = i === total - 1;
|
||
const lt = loc.location_type || '';
|
||
const isLbs = lt.startsWith('lbs');
|
||
const isWifi = lt.startsWith('wifi');
|
||
const isBt = lt === 'bluetooth';
|
||
// Color by location type: GPS=blue, WiFi=cyan, LBS=orange, BT=purple
|
||
let dotColor = '#3b82f6';
|
||
if (isFirst) dotColor = '#22c55e';
|
||
else if (isLast) dotColor = '#ef4444';
|
||
else if (isLbs) dotColor = '#f59e0b';
|
||
else if (isWifi) dotColor = '#06b6d4';
|
||
else if (isBt) dotColor = '#a855f7';
|
||
const marker = new AMap.CircleMarker({
|
||
center: [mLng, mLat],
|
||
radius: isFirst || isLast ? 12 : 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',
|
||
});
|
||
const isLP = (isLbs || isWifi) && !isFirst && !isLast;
|
||
marker._lpFlag = isLP;
|
||
if (_hideLowPrecision && isLP) marker._lpHidden = true;
|
||
else marker.setMap(locationMap);
|
||
const label = isFirst ? `起点 (1/${total})` : isLast ? `终点 (${i+1}/${total})` : `第 ${i+1}/${total} 点`;
|
||
const content = _buildInfoContent(label, loc, lat, lng);
|
||
const infoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -5) });
|
||
marker.on('click', () => infoWindow.open(locationMap, [mLng, mLat]));
|
||
mapMarkers.push(marker);
|
||
mapInfoWindows.push({ infoWindow, position: [mLng, mLat], locId: loc.id });
|
||
}
|
||
});
|
||
|
||
// Store track data for LP filtering
|
||
_trackLocations = locations;
|
||
|
||
// Build path excluding LP points if hidden
|
||
const filteredPath = _hideLowPrecision
|
||
? path.filter((_, i) => {
|
||
const lt = (locations[i]?.location_type || '').toLowerCase();
|
||
const isFirst = i === 0, isLast = i === locations.length - 1;
|
||
return isFirst || isLast || !(lt.startsWith('lbs') || lt.startsWith('wifi'));
|
||
})
|
||
: path;
|
||
|
||
if (filteredPath.length > 1) {
|
||
mapPolyline = new AMap.Polyline({ path: filteredPath, strokeColor: '#3b82f6', strokeWeight: 3, strokeOpacity: 0.8, lineJoin: 'round' });
|
||
mapPolyline.setMap(locationMap);
|
||
_trackPolyline = mapPolyline;
|
||
locationMap.setFitView([mapPolyline], false, [50, 50, 50, 50]);
|
||
} else if (filteredPath.length === 1) {
|
||
locationMap.setCenter(filteredPath[0]);
|
||
locationMap.setZoom(15);
|
||
}
|
||
|
||
const legend = document.getElementById('mapLegend');
|
||
if (legend) legend.style.display = 'block';
|
||
showToast(`已加载 ${total} 个轨迹点${_hideLowPrecision ? ' (已隐藏低精度)' : ''}`);
|
||
} catch (err) {
|
||
showToast('加载轨迹失败: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function loadLatestPosition() {
|
||
const deviceId = document.getElementById('locDeviceSelect').value;
|
||
if (!deviceId) { showToast('请选择设备', 'error'); return; }
|
||
|
||
const btn = document.querySelector('.btn-success');
|
||
const origHtml = btn.innerHTML;
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 获取中...';
|
||
|
||
try {
|
||
// Record timestamp before sending command
|
||
const sentAt = new Date().toISOString();
|
||
|
||
// Send WHERE# to request fresh position from device
|
||
try {
|
||
await apiCall(`${API_BASE}/commands/send`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ device_id: parseInt(deviceId), command_type: 'online_cmd', command_content: 'WHERE#' }),
|
||
});
|
||
showToast('已发送定位指令,等待设备回传...', 'info');
|
||
} 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;
|
||
}
|
||
|
||
if (!locationMap) initLocationMap();
|
||
clearMapOverlays();
|
||
|
||
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);
|
||
const marker = new AMap.Marker({ position: [mLng, mLat] });
|
||
marker.setMap(locationMap);
|
||
const infoContent = _buildInfoContent('实时定位', loc, lat, lng);
|
||
const infoWindow = new AMap.InfoWindow({ content: infoContent, offset: new AMap.Pixel(0, -30) });
|
||
infoWindow.open(locationMap, [mLng, mLat]);
|
||
marker.on('click', () => infoWindow.open(locationMap, [mLng, mLat]));
|
||
mapMarkers.push(marker);
|
||
locationMap.setCenter([mLng, mLat]);
|
||
locationMap.setZoom(15);
|
||
showToast('已获取设备实时位置');
|
||
loadLocationRecords(1);
|
||
} catch (err) {
|
||
showToast('获取位置失败: ' + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerHTML = origHtml;
|
||
}
|
||
}
|
||
|
||
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}`;
|
||
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 || [];
|
||
_locTableItems = items; // cache for focusMapPoint
|
||
const tbody = document.getElementById('locationsTableBody');
|
||
|
||
if (items.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-gray-500 py-8">没有位置记录</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = items.map(l => {
|
||
const q = _locQuality(l);
|
||
const hasCoord = l.latitude != null && l.longitude != null;
|
||
const lpHide = _hideLowPrecision && _isLowPrecision(l.location_type);
|
||
return `<tr data-loc-type="${l.location_type || ''}" style="cursor:${hasCoord?'pointer':'default'}${lpHide ? ';display:none' : ''}" ${hasCoord ? `onclick="focusMapPoint(${l.id})"` : ''}>
|
||
<td onclick="event.stopPropagation()"><input type="checkbox" class="loc-sel-cb" value="${l.id}" onchange="updateLocSelCount()"></td>
|
||
<td class="font-mono text-xs">${escapeHtml(l.device_id || '-')}</td>
|
||
<td>${_locTypeLabel(l.location_type)}</td>
|
||
<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>
|
||
<td><span class="badge" style="background:${q.bg};color:${q.fg}">${q.label}</span></td>
|
||
<td class="text-xs text-gray-400">${formatTime(l.recorded_at || l.created_at)}</td>
|
||
<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('');
|
||
}
|
||
document.getElementById('locSelectAll').checked = false;
|
||
updateLocSelCount();
|
||
renderPagination('locationsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadLocationRecords');
|
||
} catch (err) {
|
||
showToast('加载位置记录失败: ' + err.message, 'error');
|
||
document.getElementById('locationsTableBody').innerHTML = '<tr><td colspan="10" class="text-center text-red-400 py-8">加载失败</td></tr>';
|
||
} 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;
|
||
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) {
|
||
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-gray-500 py-8">没有告警记录</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = items.map(a => `
|
||
<tr>
|
||
<td onclick="event.stopPropagation()"><input type="checkbox" class="alarm-sel-cb" value="${a.id}" onchange="updateSelCount('alarm-sel-cb','alarmSelCount','btnBatchDeleteAlarm')"></td>
|
||
<td class="font-mono text-xs">${escapeHtml(a.device_id || '-')}</td>
|
||
<td><span class="${alarmTypeClass(a.alarm_type)} font-semibold">${alarmTypeName(a.alarm_type)}</span></td>
|
||
<td>${escapeHtml(a.alarm_source || '-')}</td>
|
||
<td class="text-xs">${a.address ? escapeHtml(a.address) : (a.latitude != null ? Number(a.latitude).toFixed(6) + ', ' + Number(a.longitude).toFixed(6) : '-')}</td>
|
||
<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('');
|
||
}
|
||
document.getElementById('alarmSelectAll').checked = false;
|
||
renderPagination('alarmsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadAlarms');
|
||
} catch (err) {
|
||
showToast('加载告警失败: ' + err.message, 'error');
|
||
document.getElementById('alarmsTableBody').innerHTML = '<tr><td colspan="10" class="text-center text-red-400 py-8">加载失败</td></tr>';
|
||
} finally {
|
||
hideLoading('alarmsLoading');
|
||
}
|
||
}
|
||
|
||
async function toggleAlarmAck(id, acknowledged) {
|
||
try {
|
||
await apiCall(`${API_BASE}/alarms/${id}/acknowledge`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ acknowledged }),
|
||
});
|
||
showToast(acknowledged ? '告警已确认' : '告警已取消确认');
|
||
loadAlarms();
|
||
loadAlarmStats();
|
||
} catch (err) {
|
||
showToast('操作失败: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ==================== 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;
|
||
} 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;
|
||
const attSource = document.getElementById('attSourceFilter').value;
|
||
const startTime = document.getElementById('attStartDate').value;
|
||
const endTime = document.getElementById('attEndDate').value;
|
||
|
||
let url = `${API_BASE}/attendance?page=${p}&page_size=${ps}`;
|
||
if (deviceId) url += `&device_id=${deviceId}`;
|
||
if (attType) url += `&attendance_type=${attType}`;
|
||
if (attSource) url += `&attendance_source=${attSource}`;
|
||
if (startTime) url += `&start_time=${startTime}T00:00:00`;
|
||
if (endTime) url += `&end_time=${endTime}T23:59:59`;
|
||
|
||
showLoading('attendanceLoading');
|
||
try {
|
||
const data = await apiCall(url);
|
||
const items = data.items || [];
|
||
const tbody = document.getElementById('attendanceTableBody');
|
||
|
||
if (items.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-gray-500 py-8">没有考勤记录</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = items.map(a => {
|
||
const posStr = a.address || (a.latitude != null ? `${Number(a.latitude).toFixed(6)}, ${Number(a.longitude).toFixed(6)}` : '-');
|
||
const gpsIcon = a.gps_positioned ? '<i class="fas fa-satellite text-green-400" title="GPS定位"></i>' : '<i class="fas fa-broadcast-tower text-yellow-400" title="基站/WiFi定位"></i>';
|
||
const battStr = a.battery_level != null ? `${a.battery_level}%` : '-';
|
||
const sigStr = a.gsm_signal != null ? `GSM:${a.gsm_signal}` : '';
|
||
const lbsStr = a.mcc != null ? `${a.mcc}/${a.mnc || 0}/${a.lac || 0}/${a.cell_id || 0}` : '-';
|
||
const srcLabel = {'device':'<i class="fas fa-mobile-alt"></i> 设备','bluetooth':'<i class="fab fa-bluetooth-b"></i> 蓝牙','fence':'<i class="fas fa-draw-polygon"></i> 围栏'}[a.attendance_source] || a.attendance_source || '设备';
|
||
const srcColor = {'device':'#9ca3af','bluetooth':'#818cf8','fence':'#34d399'}[a.attendance_source] || '#9ca3af';
|
||
return `<tr>
|
||
<td onclick="event.stopPropagation()"><input type="checkbox" class="att-sel-cb" value="${a.id}" onchange="updateSelCount('att-sel-cb','attSelCount','btnBatchDeleteAtt')"></td>
|
||
<td class="font-mono text-xs">${escapeHtml(a.device_id || '-')}</td>
|
||
<td><span class="${a.attendance_type === 'clock_in' ? 'text-green-400' : 'text-blue-400'} font-semibold">${attendanceTypeName(a.attendance_type)}</span></td>
|
||
<td style="color:${srcColor};font-size:12px">${srcLabel}</td>
|
||
<td class="text-xs">${gpsIcon} ${escapeHtml(posStr)}</td>
|
||
<td class="text-xs">${battStr} ${sigStr}</td>
|
||
<td class="text-xs font-mono">${lbsStr}</td>
|
||
<td class="text-xs text-gray-400">${formatTime(a.recorded_at)}</td>
|
||
<td><button class="btn btn-sm" style="color:#ef4444;padding:2px 8px;font-size:11px" onclick="deleteAttendance(${a.id})"><i class="fas fa-trash-alt"></i></button></td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
document.getElementById('attSelectAll').checked = false;
|
||
renderPagination('attendancePagination', data.total || 0, data.page || p, data.page_size || ps, 'loadAttendance');
|
||
} catch (err) {
|
||
showToast('加载考勤记录失败: ' + err.message, 'error');
|
||
document.getElementById('attendanceTableBody').innerHTML = '<tr><td colspan="9" class="text-center text-red-400 py-8">加载失败</td></tr>';
|
||
} finally {
|
||
hideLoading('attendanceLoading');
|
||
}
|
||
}
|
||
|
||
async function deleteAttendance(id) {
|
||
if (!confirm('确认删除此考勤记录?')) return;
|
||
try {
|
||
await apiCall(`${API_BASE}/attendance/${id}`, {method: 'DELETE'});
|
||
showToast('删除成功', 'success');
|
||
loadAttendance();
|
||
loadAttendanceStats();
|
||
} catch (err) {
|
||
showToast('删除失败: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ==================== BLUETOOTH ====================
|
||
async function loadBluetooth(page) {
|
||
if (page) pageState.bluetooth.page = page;
|
||
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) {
|
||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-gray-500 py-8">没有蓝牙记录</td></tr>';
|
||
} 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>
|
||
<td onclick="event.stopPropagation()"><input type="checkbox" class="bt-sel-cb" value="${b.id}" onchange="updateSelCount('bt-sel-cb','btSelCount','btnBatchDeleteBt')"></td>
|
||
<td class="font-mono text-xs">${escapeHtml(b.device_id || '-')}</td>
|
||
<td>${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('');
|
||
}
|
||
document.getElementById('btSelectAll').checked = false;
|
||
renderPagination('bluetoothPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadBluetooth');
|
||
} catch (err) {
|
||
showToast('加载蓝牙记录失败: ' + err.message, 'error');
|
||
document.getElementById('bluetoothTableBody').innerHTML = '<tr><td colspan="9" class="text-center text-red-400 py-8">加载失败</td></tr>';
|
||
} 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');
|
||
}
|
||
}
|
||
|
||
// ==================== 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) {
|
||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-gray-500 py-8">暂无记录</td></tr>';
|
||
} 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>
|
||
<td onclick="event.stopPropagation()"><input type="checkbox" class="log-sel-cb" value="${r.id}" onchange="updateSelCount('log-sel-cb','logSelCount','btnBatchDeleteLog')"></td>
|
||
<td class="font-mono text-xs">${r.id}</td>
|
||
<td>${typeBadge}</td>
|
||
<td>${r.device_id || '-'}</td>
|
||
<td class="font-mono text-xs">${escapeHtml(r.imei || (logDeviceMap[r.device_id] || {}).imei || '-')}</td>
|
||
<td class="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('');
|
||
}
|
||
document.getElementById('logSelectAll').checked = false;
|
||
renderPagination('datalogPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadDataLog');
|
||
} catch (err) {
|
||
document.getElementById('datalogTableBody').innerHTML = '<tr><td colspan="9" class="text-center text-red-400 py-8">加载失败: ' + escapeHtml(err.message) + '</td></tr>';
|
||
} 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');
|
||
}
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
// ---- Fence Tab switching & binding tab ----
|
||
function switchFenceTab(tab) {
|
||
document.getElementById('fenceTabList').classList.toggle('active', tab === 'list');
|
||
document.getElementById('fenceTabBindings').classList.toggle('active', tab === 'bindings');
|
||
document.getElementById('fenceTabContentList').style.display = tab === 'list' ? 'flex' : 'none';
|
||
document.getElementById('fenceTabContentBindings').style.display = tab === 'bindings' ? 'flex' : 'none';
|
||
if (tab === 'bindings') loadBindingMatrix();
|
||
}
|
||
|
||
let _bindMatrixState = {}; // { "fenceId-deviceId": true/false }
|
||
let _bindMatrixOriginal = {}; // original state for diff
|
||
let _bindFences = [];
|
||
let _bindDevices = [];
|
||
|
||
async function loadBindingMatrix() {
|
||
const thead = document.getElementById('fenceBindMatrixHead');
|
||
const tbody = document.getElementById('fenceBindMatrixBody');
|
||
try {
|
||
const [fenceData, deviceData] = await Promise.all([
|
||
apiCall(`${API_BASE}/fences?page=1&page_size=100`),
|
||
apiCall(`${API_BASE}/devices?page=1&page_size=100`),
|
||
]);
|
||
_bindFences = fenceData.items || [];
|
||
_bindDevices = deviceData.items || [];
|
||
|
||
if (!_bindFences.length) {
|
||
thead.innerHTML = '';
|
||
tbody.innerHTML = '<tr><td class="text-center text-gray-500 py-4">暂无围栏,请先创建</td></tr>';
|
||
return;
|
||
}
|
||
if (!_bindDevices.length) {
|
||
thead.innerHTML = '';
|
||
tbody.innerHTML = '<tr><td class="text-center text-gray-500 py-4">暂无设备</td></tr>';
|
||
return;
|
||
}
|
||
|
||
// Fetch bindings for all fences in parallel
|
||
const bindingsArr = await Promise.all(
|
||
_bindFences.map(f => apiCall(`${API_BASE}/fences/${f.id}/devices`).catch(() => []))
|
||
);
|
||
|
||
// Build state
|
||
_bindMatrixState = {};
|
||
_bindMatrixOriginal = {};
|
||
_bindFences.forEach((f, i) => {
|
||
const bound = bindingsArr[i] || [];
|
||
const boundIds = new Set(bound.map(b => b.device_id));
|
||
_bindDevices.forEach(d => {
|
||
const key = `${f.id}-${d.id}`;
|
||
const val = boundIds.has(d.id);
|
||
_bindMatrixState[key] = val;
|
||
_bindMatrixOriginal[key] = val;
|
||
});
|
||
});
|
||
|
||
// Render header
|
||
const fenceTypeName = t => ({circle:'圆',polygon:'多边',rectangle:'矩'}[t] || t);
|
||
thead.innerHTML = `<tr>
|
||
<th style="position:sticky;left:0;background:#1f2937;z-index:2;min-width:120px">设备 \\ 围栏</th>
|
||
${_bindFences.map(f => `<th style="text-align:center;min-width:80px;font-size:11px;white-space:nowrap" title="${escapeHtml(f.name)}">${escapeHtml(f.name)}<br><span style="color:#6b7280;font-weight:normal">${fenceTypeName(f.fence_type)}</span></th>`).join('')}
|
||
<th style="text-align:center;min-width:60px">全选</th>
|
||
</tr>`;
|
||
|
||
// Render body
|
||
tbody.innerHTML = _bindDevices.map(d => {
|
||
const label = d.name || d.imei || d.id;
|
||
const statusDot = d.status === 'online' ? '🟢' : '⚪';
|
||
return `<tr>
|
||
<td style="position:sticky;left:0;background:#111827;z-index:1;white-space:nowrap;font-size:12px">${statusDot} ${escapeHtml(label)}</td>
|
||
${_bindFences.map(f => {
|
||
const key = `${f.id}-${d.id}`;
|
||
const checked = _bindMatrixState[key] ? 'checked' : '';
|
||
return `<td style="text-align:center"><input type="checkbox" ${checked} onchange="_bindMatrixState['${key}']=this.checked;updateBindSaveBtn()"></td>`;
|
||
}).join('')}
|
||
<td style="text-align:center"><input type="checkbox" onchange="toggleDeviceRow(${d.id},this.checked)"></td>
|
||
</tr>`;
|
||
}).join('') + `<tr style="border-top:1px solid #374151">
|
||
<td style="position:sticky;left:0;background:#111827;z-index:1;font-size:12px;color:#9ca3af">全选列</td>
|
||
${_bindFences.map(f => `<td style="text-align:center"><input type="checkbox" onchange="toggleFenceCol(${f.id},this.checked)"></td>`).join('')}
|
||
<td style="text-align:center"><input type="checkbox" onchange="toggleAllBindings(this.checked)"></td>
|
||
</tr>`;
|
||
updateBindSaveBtn();
|
||
} catch (err) {
|
||
thead.innerHTML = '';
|
||
tbody.innerHTML = `<tr><td class="text-center text-red-400 py-4">加载失败: ${escapeHtml(err.message)}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
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() {
|
||
// Compute diffs per fence: which devices to bind, which to unbind
|
||
const toBind = {}; // fenceId -> [deviceIds]
|
||
const toUnbind = {}; // fenceId -> [deviceIds]
|
||
for (const key in _bindMatrixState) {
|
||
if (_bindMatrixState[key] === _bindMatrixOriginal[key]) continue;
|
||
const [fenceId, deviceId] = key.split('-').map(Number);
|
||
if (_bindMatrixState[key]) {
|
||
(toBind[fenceId] = toBind[fenceId] || []).push(deviceId);
|
||
} else {
|
||
(toUnbind[fenceId] = toUnbind[fenceId] || []).push(deviceId);
|
||
}
|
||
}
|
||
const ops = [];
|
||
for (const fid in toBind) {
|
||
ops.push(apiCall(`${API_BASE}/fences/${fid}/devices`, {
|
||
method: 'POST', body: JSON.stringify({ device_ids: toBind[fid] }),
|
||
}));
|
||
}
|
||
for (const fid in toUnbind) {
|
||
ops.push(apiCall(`${API_BASE}/fences/${fid}/devices`, {
|
||
method: 'DELETE', body: JSON.stringify({ device_ids: toUnbind[fid] }),
|
||
}));
|
||
}
|
||
if (!ops.length) { showToast('没有更改', 'info'); return; }
|
||
try {
|
||
await Promise.all(ops);
|
||
showToast(`保存成功 (${ops.length} 项操作)`, 'success');
|
||
loadBindingMatrix(); // reload to sync state
|
||
} catch (err) {
|
||
showToast('保存失败: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ---- 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;
|
||
|
||
_beaconPickerMap = new AMap.Map(mapDivId, {
|
||
zoom: zoom,
|
||
center: [mLng, mLat],
|
||
mapStyle: 'amap://styles/normal',
|
||
});
|
||
|
||
if (hasInit) {
|
||
_beaconPickerMarker = new AMap.Marker({ position: [mLng, mLat] });
|
||
_beaconPickerMarker.setMap(_beaconPickerMap);
|
||
}
|
||
|
||
_beaconPickerMap.on('click', async (e) => {
|
||
const gcjLng = e.lnglat.getLng(), gcjLat = e.lnglat.getLat();
|
||
const [wgsLat, wgsLng] = gcj02ToWgs84(gcjLat, gcjLng);
|
||
document.getElementById(latInputId).value = wgsLat.toFixed(6);
|
||
document.getElementById(lonInputId).value = wgsLng.toFixed(6);
|
||
if (_beaconPickerMarker) _beaconPickerMarker.setPosition([gcjLng, gcjLat]);
|
||
else { _beaconPickerMarker = new AMap.Marker({ position: [gcjLng, gcjLat] }); _beaconPickerMarker.setMap(_beaconPickerMap); }
|
||
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);
|
||
if (_beaconPickerMarker) _beaconPickerMarker.setPosition([mLng, mLat]);
|
||
else { _beaconPickerMarker = new AMap.Marker({ position: [mLng, mLat] }); _beaconPickerMarker.setMap(_beaconPickerMap); }
|
||
_beaconPickerMap.setZoomAndCenter(16, [mLng, mLat]);
|
||
}
|
||
};
|
||
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) {
|
||
if (_beaconPickerMarker) _beaconPickerMarker.setPosition([gcjLng, gcjLat]);
|
||
else { _beaconPickerMarker = new AMap.Marker({ position: [gcjLng, gcjLat] }); _beaconPickerMarker.setMap(_beaconPickerMap); }
|
||
_beaconPickerMap.setZoomAndCenter(16, [gcjLng, gcjLat]);
|
||
}
|
||
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() {
|
||
if (_beaconPickerMap) { _beaconPickerMap.destroy(); _beaconPickerMap = null; }
|
||
_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');
|
||
}
|
||
}
|
||
|
||
// ==================== COMMANDS ====================
|
||
async function sendCommand() {
|
||
const deviceId = document.getElementById('cmdDeviceSelect').value;
|
||
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() {
|
||
const deviceId = document.getElementById('msgDeviceSelect').value;
|
||
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() {
|
||
const deviceId = document.getElementById('ttsDeviceSelect').value;
|
||
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');
|
||
}
|
||
}
|
||
|
||
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>
|
||
<td class="font-mono text-xs">${escapeHtml(c.device_id || c.imei || '-')}</td>
|
||
<td>${escapeHtml(c.command_type || '-')}</td>
|
||
<td class="text-xs" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(c.command_content || '')}">${escapeHtml(truncate(c.command_content || '-', 40))}</td>
|
||
<td>${commandStatusBadge(c.status)}</td>
|
||
<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');
|
||
}
|
||
}
|
||
|
||
// ==================== INITIALIZATION ====================
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
navigateTo('dashboard');
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|