Files
iot-test-platform/target/classes/static/index.html
lzh 1b2c50683a
All checks were successful
iot-test-platform CI/CD / build-and-deploy (push) Successful in 24s
fix: 数据补报解析
2025-12-15 15:34:01 +08:00

649 lines
36 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>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>
<li class="nav-item">
<button class="nav-link py-2 small" @click="mode='command'" :class="{active: mode==='command'}">指令下发</button>
</li>
</ul>
<div v-if="mode === 'badge'">
<div v-if="mode === 'command'">
<div class="mb-3">
<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-3">
<label class="form-label small text-muted">请求参数 (JSON)</label>
<textarea v-model="commandForm.jsonBody" class="form-control font-monospace form-control-sm" rows="8"></textarea>
</div>
<button @click="sendCommand" class="btn btn-primary btn-sm w-100">
<i class="fas fa-terminal me-2"></i>发送指令
</button>
</div>
<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">
<i class="fas fa-paper-plane me-2"></i>发送工牌数据
</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>
</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">
<span>MAC: {{ ble.mac }}</span>
<span>RSSI: {{ ble.rssi }} dBm</span>
</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 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-3 border-bottom pb-2">
<h6 class="fw-bold mb-0 text-success"><i class="fas fa-door-open me-2"></i>{{ counter.id }}</h6>
<small class="text-muted">{{ formatTime(counter.lastUpdate) }}</small>
</div>
<div class="row text-center">
<div class="col-4 border-end">
<div class="h3 mb-0 text-primary">{{ counter.inCount }}</div>
<small class="text-muted text-uppercase" style="font-size: 0.7rem;">In</small>
</div>
<div class="col-4 border-end">
<div class="h3 mb-0 text-warning">{{ counter.outCount }}</div>
<small class="text-muted text-uppercase" style="font-size: 0.7rem;">Out</small>
</div>
<div class="col-4">
<div class="h3 mb-0 text-dark">{{ counter.inCount - counter.outCount }}</div>
<small class="text-muted text-uppercase" style="font-size: 0.7rem;">Stay</small>
</div>
</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: []
}
},
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)
else if (data.type === 'counter' && data.id) {
this.counters[data.id] = {
...data,
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)
});
const result = await res.json();
if (result.code !== 200) alert('Error: ' + result.message);
} 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 '';
return new Date(date).toLocaleTimeString();
},
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;
}
}
}).mount('#app')
</script>
</body>
</html>