Files
desungongpai/app/static/admin.html
default 61c300bad8 feat: 告警/考勤/蓝牙/数据日志页面添加批量删除功能
- 新增 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>
2026-03-31 02:03:21 +00:00

4107 lines
248 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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">&#9679;</span> GPS
<span style="color:#06b6d4;margin-left:8px">&#9679;</span> WiFi <span style="color:#9ca3af">(~80m)</span>
<span style="color:#f59e0b;margin-left:8px">&#9675;</span> LBS <span style="color:#9ca3af">(~1km)</span>
<span style="color:#a855f7;margin-left:8px">&#9679;</span> 蓝牙
</div>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="locationsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
<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 ? '&nbsp;|&nbsp; 信号: ' + d.gsm_signal : ''}</div>
<div><span class="text-gray-400">ICCID:</span><br><span class="font-mono" style="font-size:11px">${escapeHtml(d.iccid || '-')}</span></div>
<div><span class="text-gray-400">时区/语言:</span><br>${escapeHtml(d.timezone || '-')} / ${escapeHtml(d.language || '-')}</div>
<div><span class="text-gray-400">最后登录:</span><br>${formatTime(d.last_login)}</div>
<div><span class="text-gray-400">最后心跳:</span><br>${formatTime(d.last_heartbeat)}</div>
</div>
<!-- 定位信息 -->
<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>