Files
desungongpai/app/static/admin.html

2076 lines
122 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>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></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: 50; display: flex; align-items: center; justify-content: center; }
.modal-content { background: #1f2937; border-radius: 12px; padding: 24px; max-width: 600px; width: 90%; max-height: 85vh; overflow-y: auto; border: 1px solid #374151; }
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 100; display: flex; flex-direction: column; gap: 8px; }
.toast { 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; }
.leaflet-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; }
</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="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>:点击绿色按钮获取设备最新定位并标注地图</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="flex flex-wrap items-center gap-3 mb-4">
<select id="locDeviceSelect" style="width:200px">
<option value="">选择设备...</option>
</select>
<select id="locTypeFilter" style="width:150px">
<option value="">全部类型</option>
<option value="gps">GPS</option>
<option value="gps_4g">GPS 4G</option>
<option value="wifi">WiFi</option>
<option value="wifi_4g">WiFi 4G</option>
<option value="lbs">LBS</option>
<option value="lbs_4g">LBS 4G</option>
</select>
<input type="date" id="locStartDate" style="width:160px">
<input type="date" id="locEndDate" style="width:160px">
<button class="btn btn-primary" onclick="loadTrack()"><i class="fas fa-route"></i> 显示轨迹</button>
<button class="btn btn-success" onclick="loadLatestPosition()"><i class="fas fa-crosshairs"></i> 最新位置</button>
<button class="btn btn-secondary" onclick="loadLocationRecords()"><i class="fas fa-list"></i> 查询记录</button>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden mb-6" style="height: 500px;">
<div id="locationMap" style="height: 100%; width: 100%;"></div>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="locationsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>设备ID</th>
<th>类型</th>
<th>纬度</th>
<th>经度</th>
<th>地址</th>
<th>速度</th>
<th>卫星数</th>
<th>时间</th>
</tr>
</thead>
<tbody id="locationsTableBody">
<tr><td colspan="8" class="text-center text-gray-500 py-8">请选择设备并查询</td></tr>
</tbody>
</table>
</div>
<div id="locationsPagination" class="pagination p-4"></div>
</div>
</div>
<!-- ==================== 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>
</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>设备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="9" 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>
<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>
</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>设备ID</th>
<th>类型</th>
<th>位置</th>
<th>电量/信号</th>
<th>基站</th>
<th>时间</th>
</tr>
</thead>
<tbody id="attendanceTableBody">
<tr><td colspan="6" 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>
</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>设备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="8" 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>
<!-- Filters & Controls -->
<div class="flex flex-wrap items-center gap-3 mb-4">
<input type="text" id="beaconSearch" placeholder="搜索 MAC / 名称 / 区域" style="width:220px">
<select id="beaconStatusFilter" style="width:150px">
<option value="">全部状态</option>
<option value="active">启用</option>
<option value="inactive">停用</option>
</select>
<button class="btn btn-primary" onclick="loadBeacons()"><i class="fas fa-search"></i> 查询</button>
<button class="btn btn-secondary" onclick="loadBeacons()"><i class="fas fa-sync-alt"></i> 刷新</button>
<div style="flex:1"></div>
<button class="btn btn-primary" onclick="showAddBeaconModal()"><i class="fas fa-plus"></i> 添加信标</button>
</div>
<!-- Table -->
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="beaconsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>MAC 地址</th>
<th>名称</th>
<th>UUID / Major / Minor</th>
<th>楼层 / 区域</th>
<th>坐标</th>
<th>状态</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="beaconsTableBody">
<tr><td colspan="8" class="text-center text-gray-500 py-8">加载中...</td></tr>
</tbody>
</table>
</div>
<div id="beaconsPagination" 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 dashAlarmChart = null;
let alarmTypeChart = 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) {
throw new Error(data.message || data.detail || `HTTP ${response.status}`);
}
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() {
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: '信标管理', 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; }
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 'commands': loadCommands(); loadDeviceSelectors(); break;
}
}
// ==================== 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;
});
} 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');
}
}
async function showDeviceDetail(id) {
if (!id) return;
try {
const device = await apiCall(`${API_BASE}/devices/${id}`);
const d = device;
showModal(`
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-microchip mr-2 text-blue-400"></i>设备详情</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div><span class="text-gray-400">ID:</span><br><span class="font-mono">${escapeHtml(d.id || d.device_id)}</span></div>
<div><span class="text-gray-400">IMEI:</span><br><span class="font-mono">${escapeHtml(d.imei)}</span></div>
<div><span class="text-gray-400">名称:</span><br>${escapeHtml(d.name || '-')}</div>
<div><span class="text-gray-400">类型:</span><br>${escapeHtml(d.device_type || '-')}</div>
<div><span class="text-gray-400">状态:</span><br>${statusBadge(d.status)}</div>
<div><span class="text-gray-400">电量:</span><br>${d.battery_level !== undefined && d.battery_level !== null ? d.battery_level + '%' : '-'}</div>
<div><span class="text-gray-400">信号强度:</span><br>${d.signal_strength !== undefined && d.signal_strength !== null ? d.signal_strength : '-'}</div>
<div><span class="text-gray-400">固件版本:</span><br>${escapeHtml(d.firmware_version || '-')}</div>
<div><span class="text-gray-400">时区:</span><br>${escapeHtml(d.timezone || '-')}</div>
<div><span class="text-gray-400">语言:</span><br>${escapeHtml(d.language || '-')}</div>
<div><span class="text-gray-400">最后登录:</span><br>${formatTime(d.last_login)}</div>
<div><span class="text-gray-400">最后心跳:</span><br>${formatTime(d.last_heartbeat)}</div>
<div class="col-span-2"><span class="text-gray-400">创建时间:</span><br>${formatTime(d.created_at)}</div>
</div>
<div class="flex gap-3 mt-6">
<button class="btn btn-primary flex-1" onclick="showEditDeviceModal('${d.id || d.device_id}')"><i class="fas fa-edit"></i> 编辑</button>
<button class="btn btn-danger flex-1" onclick="confirmDeleteDevice('${d.id || d.device_id}', '${escapeHtml(d.name || d.imei)}')"><i class="fas fa-trash"></i> 删除</button>
<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');
}
}
// ==================== LOCATIONS ====================
function initLocationMap() {
if (locationMap) return;
setTimeout(() => {
locationMap = L.map('locationMap').setView([39.9042, 116.4074], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19,
}).addTo(locationMap);
locationMap.invalidateSize();
}, 100);
}
function clearMapOverlays() {
mapMarkers.forEach(m => locationMap.removeLayer(m));
mapMarkers = [];
if (mapPolyline) { locationMap.removeLayer(mapPolyline); mapPolyline = 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 latlngs = [];
locations.forEach((loc, i) => {
const lat = loc.latitude || loc.lat;
const lng = loc.longitude || loc.lng || loc.lon;
if (lat && lng) {
latlngs.push([lat, lng]);
const isFirst = i === 0;
const isLast = i === locations.length - 1;
const marker = L.circleMarker([lat, lng], {
radius: isFirst || isLast ? 8 : 4,
fillColor: isFirst ? '#22c55e' : isLast ? '#ef4444' : '#3b82f6',
color: '#fff', weight: 1, fillOpacity: 0.9,
}).addTo(locationMap);
marker.bindPopup(`
<b>${isFirst ? '起点' : isLast ? '终点' : `点 #${i + 1}`}</b><br>
类型: ${loc.location_type || '-'}<br>
坐标: ${lat.toFixed(6)}, ${lng.toFixed(6)}<br>
${loc.address ? `地址: ${escapeHtml(loc.address)}<br>` : ''}
时间: ${formatTime(loc.recorded_at || loc.created_at)}
`);
mapMarkers.push(marker);
}
});
if (latlngs.length > 1) {
mapPolyline = L.polyline(latlngs, { color: '#3b82f6', weight: 3, opacity: 0.8 }).addTo(locationMap);
locationMap.fitBounds(mapPolyline.getBounds().pad(0.1));
} else if (latlngs.length === 1) {
locationMap.setView(latlngs[0], 15);
}
showToast(`已加载 ${locations.length} 个轨迹点`);
} catch (err) {
showToast('加载轨迹失败: ' + err.message, 'error');
}
}
async function loadLatestPosition() {
const deviceId = document.getElementById('locDeviceSelect').value;
if (!deviceId) { showToast('请选择设备', 'error'); return; }
try {
const loc = await apiCall(`${API_BASE}/locations/latest/${deviceId}`);
if (!loc) { showToast('暂无位置数据', 'info'); 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 marker = L.marker([lat, lng]).addTo(locationMap);
marker.bindPopup(`
<b>最新位置</b><br>
类型: ${loc.location_type || '-'}<br>
坐标: ${lat.toFixed(6)}, ${lng.toFixed(6)}<br>
${loc.address ? `地址: ${escapeHtml(loc.address)}<br>` : ''}
速度: ${loc.speed || '-'}<br>
时间: ${formatTime(loc.recorded_at || loc.created_at)}
`).openPopup();
mapMarkers.push(marker);
locationMap.setView([lat, lng], 15);
showToast('已显示最新位置');
} catch (err) {
showToast('获取最新位置失败: ' + err.message, 'error');
}
}
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 || [];
const tbody = document.getElementById('locationsTableBody');
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(l => `
<tr>
<td class="font-mono text-xs">${escapeHtml(l.device_id || '-')}</td>
<td>${escapeHtml(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>${l.gps_satellites != null ? l.gps_satellites : '-'}</td>
<td class="text-xs text-gray-400">${formatTime(l.recorded_at || l.created_at)}</td>
</tr>
`).join('');
}
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="8" 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="9" class="text-center text-gray-500 py-8">没有告警记录</td></tr>';
} else {
tbody.innerHTML = items.map(a => `
<tr>
<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('');
}
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="9" 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 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 (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="6" 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}` : '-';
return `<tr>
<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 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>
</tr>`;
}).join('');
}
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="6" class="text-center text-red-400 py-8">加载失败</td></tr>';
} finally {
hideLoading('attendanceLoading');
}
}
// ==================== 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="8" 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 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('');
}
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="8" 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');
} 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');
}
}
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"></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"></div>
<div class="form-group"><label>Minor</label><input type="number" id="addBeaconMinor" placeholder="0-65535" min="0" max="65535"></div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="form-group"><label>楼层</label><input type="text" id="addBeaconFloor" placeholder="如: 3F"></div>
<div class="form-group"><label>区域</label><input type="text" id="addBeaconArea" placeholder="如: A区会议室"></div>
</div>
<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="可选"></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>
`);
}
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 floor = document.getElementById('addBeaconFloor').value.trim();
const area = document.getElementById('addBeaconArea').value.trim();
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 (floor) body.floor = floor;
if (area) body.area = area;
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>名称</label><input type="text" id="editBeaconName" value="${escapeHtml(b.name || '')}"></div>
<div class="form-group"><label>UUID</label><input type="text" id="editBeaconUuid" value="${escapeHtml(b.beacon_uuid || '')}" maxlength="36"></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"></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"></div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="form-group"><label>楼层</label><input type="text" id="editBeaconFloor" value="${escapeHtml(b.floor || '')}"></div>
<div class="form-group"><label>区域</label><input type="text" id="editBeaconArea" value="${escapeHtml(b.area || '')}"></div>
</div>
<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 || '')}"></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>
</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>
`);
} 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 floor = document.getElementById('editBeaconFloor').value.trim();
const area = document.getElementById('editBeaconArea').value.trim();
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;
body.floor = floor || null;
body.area = area || 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>