Some checks failed
iot-test-platform CI/CD / build-and-deploy (push) Failing after 45s
800 lines
45 KiB
HTML
800 lines
45 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>IoT 设备测试平台</title>
|
||
<!-- Vue 3 -->
|
||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||
<!-- Bootstrap 5 -->
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
<!-- Font Awesome -->
|
||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--primary-bg: #f4f6f9;
|
||
--card-bg: #ffffff;
|
||
--sidebar-bg: #343a40;
|
||
--accent-color: #3b82f6;
|
||
}
|
||
body {
|
||
background-color: var(--primary-bg);
|
||
font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||
color: #333;
|
||
}
|
||
.navbar {
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
}
|
||
.dashboard-card {
|
||
background: var(--card-bg);
|
||
border: none;
|
||
border-radius: 12px;
|
||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||
transition: all 0.3s ease;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
}
|
||
.dashboard-card:hover {
|
||
transform: translateY(-5px);
|
||
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||
}
|
||
.card-header {
|
||
background-color: transparent;
|
||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||
font-weight: 600;
|
||
padding: 1rem 1.5rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.card-icon {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 1.2rem;
|
||
margin-right: 15px;
|
||
}
|
||
|
||
/* 特定板块样式 */
|
||
.badge-card .card-icon { background-color: rgba(13, 110, 253, 0.1); color: #0d6efd; }
|
||
.counter-card .card-icon { background-color: rgba(25, 135, 84, 0.1); color: #198754; }
|
||
|
||
.stat-value { font-size: 1.8rem; font-weight: bold; margin-bottom: 0; }
|
||
.stat-label { color: #6c757d; font-size: 0.9rem; }
|
||
|
||
/* 日志控制台 */
|
||
.log-console {
|
||
background-color: #1e1e1e;
|
||
color: #00ff00;
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
height: 400px;
|
||
overflow-y: auto;
|
||
padding: 15px;
|
||
border-radius: 0 0 12px 12px;
|
||
font-size: 0.85rem;
|
||
}
|
||
.log-entry {
|
||
margin-bottom: 6px;
|
||
border-bottom: 1px solid #333;
|
||
padding-bottom: 4px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
}
|
||
.log-time { color: #888; margin-right: 10px; min-width: 80px; }
|
||
.log-source { margin-right: 10px; font-weight: bold; }
|
||
.log-data { color: #ce9178; word-break: break-all; }
|
||
|
||
/* 动画 */
|
||
.fade-enter-active, .fade-leave-active { transition: opacity 0.5s; }
|
||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||
|
||
/* 设备列表项 */
|
||
.device-item {
|
||
border-left: 4px solid transparent;
|
||
padding: 12px;
|
||
margin-bottom: 12px;
|
||
background: #ffffff;
|
||
border: 1px solid #eee;
|
||
border-radius: 8px;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||
transition: all 0.2s;
|
||
}
|
||
.device-item:hover { transform: translateX(2px); box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
|
||
.device-item.active { border-left-color: #28a745; }
|
||
.device-item.inactive { border-left-color: #6c757d; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app" class="d-flex flex-column vh-100">
|
||
<!-- 导航栏 -->
|
||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark flex-shrink-0">
|
||
<div class="container-fluid">
|
||
<a class="navbar-brand" href="#">
|
||
<i class="fas fa-network-wired me-2"></i>IoT 数据测试平台
|
||
</a>
|
||
<div class="d-flex align-items-center">
|
||
<span class="text-light me-3">
|
||
<i class="fas fa-circle me-1" :class="connected ? 'text-success' : 'text-danger'"></i>
|
||
{{ connected ? '已连接实时流' : '断开连接' }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<div class="container-fluid flex-grow-1 overflow-hidden">
|
||
<div class="row h-100">
|
||
<!-- 左侧:模拟与控制 (固定宽度 350px) -->
|
||
<div class="col-auto bg-white border-end py-3 overflow-auto" style="width: 350px;">
|
||
<div class="card dashboard-card mb-4 border-0 shadow-sm">
|
||
<div class="card-header bg-white text-primary border-bottom-0">
|
||
<span><i class="fas fa-vial me-2"></i>数据模拟器</span>
|
||
</div>
|
||
<div class="card-body pt-0">
|
||
<ul class="nav nav-tabs nav-fill mb-3" id="pills-tab" role="tablist">
|
||
<li class="nav-item">
|
||
<button class="nav-link py-2 small" @click="mode='badge'" :class="{active: mode==='badge'}">工牌</button>
|
||
</li>
|
||
<li class="nav-item">
|
||
<button class="nav-link py-2 small" @click="mode='counter'" :class="{active: mode==='counter'}">计数器</button>
|
||
</li>
|
||
<li class="nav-item">
|
||
<button class="nav-link py-2 small" @click="mode='custom'" :class="{active: mode==='custom'}">自定义</button>
|
||
</li>
|
||
</ul>
|
||
|
||
<div v-if="mode === 'badge'">
|
||
<div class="mb-3">
|
||
<label class="form-label small text-muted">工牌 ID</label>
|
||
<input v-model="badgeForm.id" class="form-control form-control-sm" placeholder="BADGE-001">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label small text-muted">电量 (%)</label>
|
||
<input type="range" v-model.number="badgeForm.battery" class="form-range" min="0" max="100">
|
||
<div class="text-end text-muted small">{{ badgeForm.battery }}%</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label small text-muted">状态</label>
|
||
<select v-model="badgeForm.status" class="form-select form-select-sm">
|
||
<option value="active">活跃</option>
|
||
<option value="idle">静止</option>
|
||
<option value="sos">SOS报警</option>
|
||
</select>
|
||
</div>
|
||
<button @click="sendBadgeData" class="btn btn-primary btn-sm w-100 mb-3">
|
||
<i class="fas fa-paper-plane me-2"></i>发送工牌数据
|
||
</button>
|
||
|
||
<hr class="my-3">
|
||
<h6 class="text-muted small mb-3"><i class="fas fa-terminal me-1"></i>指令调试 (8300/8201)</h6>
|
||
<div class="mb-2">
|
||
<label class="form-label small text-muted">指令类型</label>
|
||
<select v-model="commandForm.apiType" class="form-select form-select-sm" @change="updateJsonTemplate">
|
||
<option value="location">位置查询 (8201)</option>
|
||
<option value="text">文本下发 (8300)</option>
|
||
<option value="general">通用指令 (API)</option>
|
||
</select>
|
||
</div>
|
||
<div class="mb-2">
|
||
<textarea v-model="commandForm.jsonBody" class="form-control font-monospace form-control-sm" rows="4" placeholder="JSON 参数"></textarea>
|
||
</div>
|
||
<button @click="sendCommand" class="btn btn-outline-secondary btn-sm w-100">
|
||
发送指令
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="mode === 'counter'">
|
||
<div class="mb-3">
|
||
<label class="form-label small text-muted">设备 ID</label>
|
||
<input v-model="counterForm.id" class="form-control form-control-sm" placeholder="CNT-GATE-01">
|
||
</div>
|
||
<div class="row g-2">
|
||
<div class="col-6 mb-3">
|
||
<label class="form-label small text-muted">进入人数</label>
|
||
<input type="number" v-model.number="counterForm.inCount" class="form-control form-control-sm">
|
||
</div>
|
||
<div class="col-6 mb-3">
|
||
<label class="form-label small text-muted">离开人数</label>
|
||
<input type="number" v-model.number="counterForm.outCount" class="form-control form-control-sm">
|
||
</div>
|
||
</div>
|
||
<button @click="sendCounterData" class="btn btn-success btn-sm w-100">
|
||
<i class="fas fa-paper-plane me-2"></i>发送计数数据
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="mode === 'custom'">
|
||
<div class="mb-3">
|
||
<label class="form-label small text-muted">JSON 数据</label>
|
||
<textarea v-model="customJson" class="form-control font-monospace form-control-sm" rows="5"></textarea>
|
||
</div>
|
||
<button @click="sendCustomJson" class="btn btn-secondary btn-sm w-100">发送自定义数据</button>
|
||
</div>
|
||
|
||
<div v-if="mode === 'command'">
|
||
<!-- 移除原有的指令下发面板,已合并到工牌 Tab -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 中间:实时监控与日志 (自适应宽度) -->
|
||
<div class="col d-flex flex-column h-100 p-0 overflow-hidden bg-light">
|
||
<!-- 顶部:状态卡片 (占比 60%) -->
|
||
<div class="flex-grow-1 p-4 overflow-auto" style="flex: 3;">
|
||
<div class="row g-4 h-100">
|
||
<!-- 工牌监控区域 -->
|
||
<div class="col-md-7 h-100">
|
||
<div class="card dashboard-card badge-card h-100 d-flex flex-column">
|
||
<div class="card-header flex-shrink-0">
|
||
<div class="d-flex align-items-center">
|
||
<div class="card-icon"><i class="fas fa-id-card"></i></div>
|
||
<div>
|
||
<h5 class="mb-0">智能工牌监控</h5>
|
||
<small class="text-muted">实时人员定位与状态</small>
|
||
</div>
|
||
</div>
|
||
<span class="badge bg-primary rounded-pill">{{ Object.keys(badges).length }} 在线</span>
|
||
</div>
|
||
<div class="card-body overflow-auto flex-grow-1 bg-light p-3">
|
||
<div v-if="Object.keys(badges).length === 0" class="text-center text-muted py-5">
|
||
<div class="mb-3"><i class="fas fa-satellite-dish fa-3x opacity-25"></i></div>
|
||
<p>暂无工牌数据接入</p>
|
||
</div>
|
||
<div class="row g-3">
|
||
<div v-for="(badge, id) in badges" :key="id" class="col-12 col-xl-6">
|
||
<div class="device-item h-100 d-flex flex-column"
|
||
:class="badge.status === 'active' ? 'active' : (badge.status === 'sos' ? 'border-danger' : 'inactive')">
|
||
|
||
<!-- 头部:ID与状态 -->
|
||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||
<div>
|
||
<div class="d-flex align-items-center">
|
||
<h6 class="mb-0 fw-bold me-2">{{ badge.id }}</h6>
|
||
<span v-if="badge.status === 'sos'" class="badge bg-danger">SOS</span>
|
||
<span v-else class="badge bg-success" style="font-size: 0.7rem;">在线</span>
|
||
</div>
|
||
<small class="text-muted" style="font-size: 0.75rem;">
|
||
<i class="far fa-clock me-1"></i>{{ formatTime(badge.lastUpdate) }}
|
||
</small>
|
||
</div>
|
||
<div class="text-end">
|
||
<div class="fw-bold" :class="getBatteryColor(badge.battery)">
|
||
<i class="fas fa-battery-half me-1"></i>{{ badge.battery }}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 核心指标:蓝牙与位置 -->
|
||
<div class="mt-auto pt-2 border-top">
|
||
<div class="row g-0 align-items-center text-center small">
|
||
<div class="col-6 border-end">
|
||
<div class="text-primary fw-bold mb-1">
|
||
<i class="fab fa-bluetooth-b me-1"></i>{{ (badge.bluetooth || []).length }}
|
||
</div>
|
||
<div class="text-muted" style="font-size: 0.7rem;">蓝牙信标</div>
|
||
</div>
|
||
<div class="col-6">
|
||
<div v-if="badge.location" class="text-dark fw-bold mb-1" title="点击查看详情">
|
||
{{ badge.location.lat.toFixed(6) }}, {{ badge.location.lon.toFixed(6) }}
|
||
</div>
|
||
<div v-else class="text-muted mb-1">-</div>
|
||
<div class="text-muted" style="font-size: 0.7rem;">GPS坐标</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 详细蓝牙列表 (折叠展示) -->
|
||
<div v-if="badge.bluetooth && badge.bluetooth.length > 0" class="mt-2 bg-light rounded p-2" style="font-size: 0.75rem;">
|
||
<div v-for="(ble, idx) in badge.bluetooth.slice(0, 4)" :key="idx" class="d-flex justify-content-between text-muted align-items-center mb-1">
|
||
<span class="font-monospace" style="font-size: 0.7rem;">{{ ble.mac }}</span>
|
||
<div class="d-flex align-items-center" :title="ble.rssi + ' dBm'">
|
||
<div class="progress me-1" style="width: 30px; height: 4px; background-color: #e9ecef;">
|
||
<div class="progress-bar" role="progressbar"
|
||
:style="{ width: getRssiPercentage(ble.rssi) + '%', backgroundColor: getRssiColor(ble.rssi) }">
|
||
</div>
|
||
</div>
|
||
<span style="width: 25px; text-align: right;">{{ ble.rssi }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="badge.bluetooth.length > 4" class="text-center text-primary" style="font-size: 0.7rem;">
|
||
+{{ badge.bluetooth.length - 4 }} 更多...
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 语音播报下发 -->
|
||
<div class="mt-2 pt-2 border-top">
|
||
<div class="input-group input-group-sm">
|
||
<input type="text" class="form-control form-control-sm"
|
||
v-model="ttsInputs[badge.id]"
|
||
placeholder="输入语音播报内容..."
|
||
@keyup.enter="sendTTS(badge.id)">
|
||
<button class="btn btn-outline-primary" type="button" @click="sendTTS(badge.id)">
|
||
<i class="fas fa-bullhorn"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 按键事件提示 -->
|
||
<div v-if="badge.lastButtonEvent" class="mt-2 alert alert-warning p-1 mb-0 text-center" style="font-size: 0.75rem;">
|
||
<i class="fas fa-hand-pointer me-1"></i>
|
||
{{ formatButtonEvent(badge.lastButtonEvent) }}
|
||
<span class="text-muted ms-1">({{ formatTime(badge.lastButtonEvent.timestamp) }})</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 客流计数器区域 -->
|
||
<div class="col-md-5 h-100">
|
||
<div class="card dashboard-card counter-card h-100 d-flex flex-column">
|
||
<div class="card-header flex-shrink-0">
|
||
<div class="d-flex align-items-center">
|
||
<div class="card-icon"><i class="fas fa-users"></i></div>
|
||
<div>
|
||
<h5 class="mb-0">客流计数器</h5>
|
||
<small class="text-muted">进出人流实时统计</small>
|
||
</div>
|
||
</div>
|
||
<span class="badge bg-success rounded-pill">{{ Object.keys(counters).length }} 设备</span>
|
||
</div>
|
||
<div class="card-body overflow-auto flex-grow-1 bg-light p-3">
|
||
<div v-if="Object.keys(counters).length === 0" class="text-center text-muted py-5">
|
||
<div class="mb-3"><i class="fas fa-chart-bar fa-3x opacity-25"></i></div>
|
||
<p>暂无计数器在线</p>
|
||
</div>
|
||
<div v-for="(counter, id) in counters" :key="id" class="card mb-3 border-0 shadow-sm">
|
||
<div class="card-body p-3">
|
||
<div class="d-flex justify-content-between mb-2">
|
||
<div>
|
||
<h6 class="fw-bold mb-1 text-success"><i class="fas fa-door-open me-2"></i>{{ counter.id }}</h6>
|
||
<small class="text-muted d-block" style="font-size: 0.75rem;">
|
||
RSSI:
|
||
<span :class="getRssiColorClass(counter.RSSI)">{{ counter.RSSI }} dBm</span>
|
||
</small>
|
||
</div>
|
||
<div class="text-end">
|
||
<small class="text-muted d-block">{{ formatTime(counter.lastUpdate) }}</small>
|
||
<span class="badge bg-light text-dark border mt-1">v{{ counter.version }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row text-center border-top border-bottom py-2 my-2">
|
||
<div class="col-4 border-end">
|
||
<div class="h4 mb-0 text-primary">{{ counter.inCount }}</div>
|
||
<small class="text-muted text-uppercase" style="font-size: 0.7rem;">今日进入</small>
|
||
</div>
|
||
<div class="col-4 border-end">
|
||
<div class="h4 mb-0 text-warning">{{ counter.outCount }}</div>
|
||
<small class="text-muted text-uppercase" style="font-size: 0.7rem;">今日离开</small>
|
||
</div>
|
||
<div class="col-4">
|
||
<div class="h4 mb-0 text-dark">{{ counter.inCount - counter.outCount }}</div>
|
||
<small class="text-muted text-uppercase" style="font-size: 0.7rem;">当前滞留</small>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 最近一条数据记录 -->
|
||
<div v-if="counter.latestData" class="small text-muted d-flex justify-content-between">
|
||
<span><i class="far fa-clock me-1"></i>{{ formatCounterTime(counter.latestData.time) }}</span>
|
||
<span>
|
||
<i class="fas fa-battery-half me-1"></i>接收端电量:{{counter.latestData.rxBat}}% / 电量:{{counter.latestData.txBat}}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 底部:实时日志 (占比 40%) -->
|
||
<div class="flex-grow-1 p-4 pt-0" style="flex: 2; min-height: 0;">
|
||
<div class="card dashboard-card h-100 d-flex flex-column">
|
||
<div class="card-header bg-dark text-white rounded-top flex-shrink-0 py-2">
|
||
<div class="d-flex align-items-center">
|
||
<i class="fas fa-terminal me-2"></i>
|
||
<span class="small">实时数据日志流</span>
|
||
</div>
|
||
<div>
|
||
<button @click="logs = []" class="btn btn-sm btn-outline-secondary text-white border-0 py-0">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
<span class="badge bg-secondary ms-2" style="font-size: 0.7rem;">{{ logs.length }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="log-console flex-grow-1" ref="console" style="height: auto; border-radius: 0 0 12px 12px;">
|
||
<div v-if="logs.length === 0" class="text-muted text-center mt-4 small">等待数据接入...</div>
|
||
<div v-for="(log, index) in logs" :key="index" class="log-entry small">
|
||
<span class="log-time">[{{ log.time }}]</span>
|
||
<span class="badge me-2" :class="getSourceClass(log.source)">{{ log.source }}</span>
|
||
<span class="log-data">{{ log.data }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const { createApp } = Vue
|
||
|
||
createApp({
|
||
data() {
|
||
return {
|
||
mode: 'badge',
|
||
connected: false,
|
||
eventSource: null,
|
||
// 模拟表单数据
|
||
badgeForm: {
|
||
id: 'BADGE-001',
|
||
battery: 85,
|
||
status: 'active'
|
||
},
|
||
counterForm: {
|
||
id: 'GATE-01',
|
||
inCount: 10,
|
||
outCount: 5
|
||
},
|
||
customJson: '{\n "deviceType": "sensor",\n "temp": 24.5\n}',
|
||
|
||
commandForm: {
|
||
apiType: 'location',
|
||
jsonBody: '{\n "phone": "09207455611"\n}'
|
||
},
|
||
|
||
// 实时数据存储
|
||
badges: {}, // Map: id -> badge data
|
||
counters: {}, // Map: id -> counter data
|
||
logs: [],
|
||
ttsInputs: {} // Map: badgeId -> input string
|
||
}
|
||
},
|
||
mounted() {
|
||
this.connectSSE();
|
||
},
|
||
methods: {
|
||
connectSSE() {
|
||
this.eventSource = new EventSource('/api/v1/device/logs/stream');
|
||
|
||
this.eventSource.onopen = () => {
|
||
this.connected = true;
|
||
this.addLog({ source: 'SYSTEM', message: 'SSE 连接成功,监听数据流...' });
|
||
};
|
||
|
||
this.eventSource.onerror = () => {
|
||
this.connected = false;
|
||
this.eventSource.close();
|
||
setTimeout(() => this.connectSSE(), 3000); // 重连
|
||
};
|
||
|
||
this.eventSource.addEventListener('log-event', (event) => {
|
||
try {
|
||
const rawData = JSON.parse(event.data);
|
||
this.handleIncomingData(rawData);
|
||
} catch (e) {
|
||
console.error('Parse error', e);
|
||
}
|
||
});
|
||
},
|
||
handleIncomingData(data) {
|
||
const now = new Date();
|
||
|
||
// 1. 处理工牌数据 (识别 type=badge)
|
||
if (data.type === 'badge' && data.id) {
|
||
// 合并旧数据,保留 lastButtonEvent 如果新数据没有覆盖它
|
||
const oldData = this.badges[data.id] || {};
|
||
const newButtonEvent = data.buttonEvent;
|
||
|
||
this.badges[data.id] = {
|
||
...oldData,
|
||
...data,
|
||
lastUpdate: now,
|
||
// 如果这次是按键事件,更新 lastButtonEvent;否则保留旧的(可以加个超时自动清除逻辑)
|
||
lastButtonEvent: newButtonEvent || oldData.lastButtonEvent
|
||
};
|
||
|
||
// 简单的超时清除按键提示 (5秒后消失)
|
||
if (newButtonEvent) {
|
||
setTimeout(() => {
|
||
if (this.badges[data.id] && this.badges[data.id].lastButtonEvent === newButtonEvent) {
|
||
this.badges[data.id].lastButtonEvent = null;
|
||
}
|
||
}, 5000);
|
||
}
|
||
}
|
||
|
||
// 2. 处理计数器数据 (识别 type=counter OR 含有 uuid 字段的复杂JSON)
|
||
else if ((data.type === 'counter' && data.id) || (data.uuid && data.data && Array.isArray(data.data))) {
|
||
// 统一 ID 字段
|
||
const id = data.id || data.uuid;
|
||
|
||
// 尝试解析复杂 JSON 结构
|
||
let totalIn = data.inCount || 0;
|
||
let totalOut = data.outCount || 0;
|
||
let latestData = null;
|
||
|
||
if (data.data && Array.isArray(data.data)) {
|
||
// 累加 data 数组中的流量,或者取最新一条(取决于业务逻辑,这里假设是累积值或者我们显示最新状态)
|
||
// 通常计数器上报的是当次时间段内的增量或者当前的累计值。
|
||
// 这里假设我们需要展示最新的累积值,或者自行累加。
|
||
// 简化处理:显示最新一条数据的 in/out,或者如果后端没做累加,前端简单累加
|
||
// 由于示例数据看起来像是一段时间的记录,我们取最后一条作为"最新状态"
|
||
|
||
// 找出时间最近的一条
|
||
const sortedData = [...data.data].sort((a, b) => b.time.localeCompare(a.time));
|
||
if (sortedData.length > 0) {
|
||
latestData = sortedData[0];
|
||
// 如果 JSON 里的 in/out 是累计值,直接使用;如果是增量,需要累加。
|
||
// 根据示例 "in": 22,看起来像累计值。
|
||
totalIn = latestData.in || 0; // Fix: use || 0 to avoid undefined
|
||
totalOut = latestData.out || 0; // Fix: use || 0 to avoid undefined
|
||
}
|
||
}
|
||
|
||
this.counters[id] = {
|
||
id: id,
|
||
// 优先使用外层字段,没有则使用解析出的字段
|
||
inCount: totalIn,
|
||
outCount: totalOut,
|
||
RSSI: data.RSSI,
|
||
version: data.version,
|
||
latestData: latestData,
|
||
lastUpdate: now
|
||
};
|
||
}
|
||
|
||
// 3. 添加到日志
|
||
const source = data.source || 'API';
|
||
// 移除展示不需要的元数据
|
||
const displayData = { ...data };
|
||
delete displayData.source;
|
||
delete displayData.timestamp;
|
||
|
||
this.logs.unshift({
|
||
time: now.toLocaleTimeString(),
|
||
source: source,
|
||
data: JSON.stringify(displayData)
|
||
});
|
||
|
||
if (this.logs.length > 100) this.logs.pop();
|
||
},
|
||
|
||
// 发送模拟请求
|
||
async postData(payload) {
|
||
try {
|
||
const res = await fetch('/api/v1/device/upload', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw new Error(`HTTP ${res.status}`);
|
||
}
|
||
|
||
const result = await res.json();
|
||
if (result.code !== 200) {
|
||
alert('Error: ' + (result.message || result.msg || '未知错误'));
|
||
}
|
||
} catch (e) {
|
||
alert('发送失败: ' + e.message);
|
||
}
|
||
},
|
||
|
||
sendBadgeData() {
|
||
const payload = {
|
||
type: 'badge',
|
||
id: this.badgeForm.id,
|
||
battery: this.badgeForm.battery,
|
||
status: this.badgeForm.status,
|
||
ts: Date.now()
|
||
};
|
||
this.postData(payload);
|
||
},
|
||
|
||
sendCounterData() {
|
||
const payload = {
|
||
type: 'counter',
|
||
id: this.counterForm.id,
|
||
inCount: this.counterForm.inCount,
|
||
outCount: this.counterForm.outCount,
|
||
ts: Date.now()
|
||
};
|
||
this.postData(payload);
|
||
},
|
||
|
||
sendCustomJson() {
|
||
try {
|
||
const payload = JSON.parse(this.customJson);
|
||
this.postData(payload);
|
||
} catch (e) {
|
||
alert('JSON 格式错误');
|
||
}
|
||
},
|
||
|
||
updateJsonTemplate() {
|
||
const type = this.commandForm.apiType;
|
||
if (type === 'location') {
|
||
this.commandForm.jsonBody = '{\n "phone": "09207455611"\n}';
|
||
} else if (type === 'text') {
|
||
this.commandForm.jsonBody = '{\n "phone": "09207455611",\n "content": "#2014*SET*R:#",\n "flag": 1\n}';
|
||
} else if (type === 'general') {
|
||
this.commandForm.jsonBody = '{\n "imei": "09207455611",\n "cmd": "workmode",\n "params": ["2", "300"]\n}';
|
||
}
|
||
},
|
||
|
||
async sendCommand() {
|
||
let payload;
|
||
try {
|
||
payload = JSON.parse(this.commandForm.jsonBody);
|
||
} catch (e) {
|
||
alert('JSON 格式错误');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (this.commandForm.apiType === 'location') {
|
||
// 调用 /api/v1/device/command/location
|
||
const formData = new FormData();
|
||
if (!payload.phone) { alert('缺少 phone 字段'); return; }
|
||
formData.append('phone', payload.phone);
|
||
|
||
const res = await fetch('/api/v1/device/command/location', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
const result = await res.json();
|
||
if (result.code === 200) alert('指令已发送');
|
||
else alert('失败: ' + (result.message || '未知错误'));
|
||
|
||
} else if (this.commandForm.apiType === 'text') {
|
||
// 调用 /api/v1/device/command/text
|
||
const formData = new FormData();
|
||
if (!payload.phone || !payload.content) { alert('缺少 phone 或 content 字段'); return; }
|
||
formData.append('phone', payload.phone);
|
||
formData.append('content', payload.content);
|
||
formData.append('flag', payload.flag || 1);
|
||
|
||
const res = await fetch('/api/v1/device/command/text', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
const result = await res.json();
|
||
if (result.code === 200) alert('指令已发送');
|
||
else alert('失败: ' + (result.message || '未知错误'));
|
||
|
||
} else {
|
||
// 通用指令接口 /api/v1/device/command/send
|
||
if (!payload.imei || !payload.cmd) { alert('缺少 imei 或 cmd 字段'); return; }
|
||
|
||
const res = await fetch('/api/v1/device/command/send', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const result = await res.json();
|
||
if (result.code === 200) alert('指令已发送');
|
||
else alert('失败: ' + (result.message || '未知错误'));
|
||
}
|
||
} catch (e) {
|
||
alert('发送异常: ' + e.message);
|
||
}
|
||
},
|
||
|
||
// 辅助函数
|
||
formatTime(date) {
|
||
if (!date) return '';
|
||
try {
|
||
return new Date(date).toLocaleTimeString();
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
},
|
||
getBatteryColor(level) {
|
||
if (level > 60) return 'text-success';
|
||
if (level > 20) return 'text-warning';
|
||
return 'text-danger';
|
||
},
|
||
getSourceClass(source) {
|
||
if (source === 'SYSTEM') return 'bg-info text-dark';
|
||
if (source === 'TCP') return 'bg-warning text-dark';
|
||
return 'bg-success';
|
||
},
|
||
formatButtonEvent(evt) {
|
||
if (!evt) return '';
|
||
let action = '按键';
|
||
// 0x01-0x0A: 短按
|
||
if (evt.keyId >= 0x01 && evt.keyId <= 0x0A) {
|
||
action = `短按 ${evt.keyId}号键`;
|
||
}
|
||
// 0x0B-0x14: 长按
|
||
else if (evt.keyId >= 0x0B && evt.keyId <= 0x14) {
|
||
const keyNum = evt.keyId - 0x0A;
|
||
action = `长按 ${keyNum}号键`;
|
||
}
|
||
return action;
|
||
},
|
||
// RSSI 可视化
|
||
getRssiPercentage(rssi) {
|
||
// 假设 -100dBm 为 0%,-50dBm 为 100%
|
||
const min = -100;
|
||
const max = -50;
|
||
if (rssi >= max) return 100;
|
||
if (rssi <= min) return 0;
|
||
return ((rssi - min) / (max - min)) * 100;
|
||
},
|
||
getRssiColor(rssi) {
|
||
if (rssi >= -60) return '#28a745'; // Green
|
||
if (rssi >= -75) return '#ffc107'; // Yellow
|
||
if (rssi >= -85) return '#fd7e14'; // Orange
|
||
return '#dc3545'; // Red
|
||
},
|
||
getRssiColorClass(rssi) {
|
||
if (!rssi) return 'text-muted';
|
||
if (rssi >= -60) return 'text-success';
|
||
if (rssi >= -75) return 'text-warning';
|
||
if (rssi >= -85) return 'text-warning'; // Orange-ish
|
||
return 'text-danger';
|
||
},
|
||
formatCounterTime(str) {
|
||
// 20250501093000 -> 09:30:00
|
||
if (!str || str.length < 14) return str;
|
||
return str.substring(8, 10) + ':' + str.substring(10, 12) + ':' + str.substring(12, 14);
|
||
},
|
||
// 发送 TTS
|
||
async sendTTS(phone) {
|
||
const content = this.ttsInputs[phone];
|
||
if (!content || !content.trim()) {
|
||
alert('请输入播报内容');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('phone', phone);
|
||
formData.append('content', content);
|
||
// Flag 9 = 0x08 (TTS) | 0x01 (紧急/指令) -> 混合模式,确保立刻播报
|
||
formData.append('flag', 9);
|
||
|
||
const res = await fetch('/api/v1/device/command/text', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
const result = await res.json();
|
||
if (result.code === 200) {
|
||
// 清空输入框
|
||
this.ttsInputs[phone] = '';
|
||
// 记录日志到界面以便反馈
|
||
this.addLog({ source: 'SYSTEM', message: `向 ${phone} 下发语音: ${content}` });
|
||
} else {
|
||
alert('下发失败: ' + result.message);
|
||
}
|
||
} catch (e) {
|
||
alert('网络异常: ' + e.message);
|
||
}
|
||
},
|
||
// 内部日志辅助
|
||
addLog(data) {
|
||
const now = new Date().toLocaleTimeString();
|
||
this.logs.unshift({
|
||
time: now,
|
||
source: data.source,
|
||
data: data.message || JSON.stringify(data)
|
||
});
|
||
if (this.logs.length > 50) this.logs.pop();
|
||
}
|
||
}
|
||
}).mount('#app')
|
||
</script>
|
||
</body>
|
||
</html>
|