Files
lzh cb6874b942
Some checks failed
iot-test-platform CI/CD / build-and-deploy (push) Failing after 45s
feat: 添加JT808原始数据收集功能
2026-01-16 22:42:39 +08:00

800 lines
45 KiB
HTML
Raw Permalink 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>
</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>