Initial commit of akmon project
This commit is contained in:
415
pages/test/multi_device_monitor.uvue
Normal file
415
pages/test/multi_device_monitor.uvue
Normal file
@@ -0,0 +1,415 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<view class="nav-tabs">
|
||||
<button class="nav-tab" @click="switchPage">AP网关</button>
|
||||
<button class="nav-tab active">设备监测</button>
|
||||
</view>
|
||||
<view class="header">
|
||||
<text class="title">设备实时数据监测</text>
|
||||
<text class="subtitle">总数: {{ allDevices.length }} | 在线: {{ onlineCount }}</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="grid-container" scroll-y="true">
|
||||
<view class="grid-layout">
|
||||
<view v-for="device in allDevices" :key="device.uniqueKey" class="card" :class="device.isOnline ? 'card-online' : 'card-offline'" @click="openDeviceTest(device)">
|
||||
<view class="card-header">
|
||||
<text class="card-title">{{ device.name }} ({{ device.numericId > 0 ? device.numericId.toString(16).toUpperCase() : '' }})</text>
|
||||
<view class="status-badge" :class="device.isOnline ? 'badge-online' : 'badge-offline'">
|
||||
<text class="badge-text">{{ device.isOnline ? '在线' : '离线' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card-body">
|
||||
<view class="info-row">
|
||||
<text class="label">WatchID:</text>
|
||||
<text class="value">{{ device.numericId > 0 ? device.numericId : '' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="label">MAC:</text>
|
||||
<text class="value">{{ device.mac }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="label">心率:</text>
|
||||
<text class="value highlight">{{ device.data?.heartrate ?? '-' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="label">RSSI:</text>
|
||||
<text class="value">{{ device.data?.rssi ?? '-' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="label">最后更新:</text>
|
||||
<text class="value time-value">{{ formatTime(device.lastUpdateTime) }}</text>
|
||||
</view>
|
||||
<!-- <view class="action-row" style="margin-top: 8px; display: flex; justify-content: flex-end;">
|
||||
<button size="mini" type="primary" style="font-size: 12px; margin: 0;" @click.stop="openDeviceTest(device)">测试</button>
|
||||
</view> -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import DeviceRealtimeService, { DeviceBatchItem, DeviceRealtimeSubscription } from '@/utils/deviceRealtimeService.uts'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
type DisplayDevice = {
|
||||
uniqueKey: string
|
||||
dbId: string | null
|
||||
numericId: number
|
||||
name: string
|
||||
mac: string
|
||||
isOnline: boolean
|
||||
lastUpdateTime: number
|
||||
data: DeviceBatchItem | null
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
devicesMap: new Map<string, DisplayDevice>(),
|
||||
subscription: null as DeviceRealtimeSubscription | null,
|
||||
now: Date.now(),
|
||||
timer: null as number | null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
allDevices(): DisplayDevice[] {
|
||||
const list = Array.from(this.devicesMap.values())
|
||||
// Sort: Online first, then by ID
|
||||
return list.sort((a, b) => {
|
||||
if (a.isOnline && !b.isOnline) return -1
|
||||
if (!a.isOnline && b.isOnline) return 1
|
||||
return a.numericId - b.numericId
|
||||
})
|
||||
},
|
||||
onlineCount(): number {
|
||||
let count = 0
|
||||
const list = this.allDevices
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i].isOnline) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
this.loadDbDevices()
|
||||
this.startMonitoring()
|
||||
this.timer = setInterval(() => {
|
||||
this.now = Date.now()
|
||||
this.checkOnlineStatus()
|
||||
}, 1000)
|
||||
},
|
||||
onUnload() {
|
||||
this.stopMonitoring()
|
||||
if (this.timer != null) {
|
||||
clearInterval(this.timer!)
|
||||
this.timer = null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadDbDevices() {
|
||||
try {
|
||||
const res = await supa.from('ak_devices').select('*').execute()
|
||||
if (res.error == null && res.data != null) {
|
||||
const list = res.data as UTSArray<UTSJSONObject>
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const item = list[i]
|
||||
const id = item.getString('id') ?? ''
|
||||
const name = item.getString('device_name') ?? 'Unknown'
|
||||
const mac = item.getString('device_mac') ?? ''
|
||||
|
||||
// 兼容 watch_id 可能是数字或字符串的情况
|
||||
let watchId = item.getNumber('watch_id') ?? 0
|
||||
if (watchId == 0) {
|
||||
const wStr = item.getString('watch_id')
|
||||
if (wStr != null) {
|
||||
watchId = parseInt(wStr)
|
||||
if (isNaN(watchId)) watchId = 0
|
||||
}
|
||||
}
|
||||
|
||||
let key = ''
|
||||
if (watchId > 0) {
|
||||
key = `nid:${watchId}`
|
||||
} else if (mac != '') {
|
||||
key = mac
|
||||
} else {
|
||||
key = id
|
||||
}
|
||||
|
||||
const existing = this.devicesMap.get(key)
|
||||
if (existing != null) {
|
||||
// 更新已存在的设备信息(可能是实时数据先创建的)
|
||||
existing.dbId = id
|
||||
existing.mac = mac
|
||||
existing.numericId = watchId
|
||||
// 优先显示DB中的名称
|
||||
if (name != 'Unknown' && name != '') {
|
||||
existing.name = name
|
||||
}
|
||||
} else {
|
||||
const dev: DisplayDevice = {
|
||||
uniqueKey: key,
|
||||
dbId: id,
|
||||
numericId: watchId,
|
||||
name: name,
|
||||
mac: mac,
|
||||
isOnline: false,
|
||||
lastUpdateTime: 0,
|
||||
data: null
|
||||
}
|
||||
this.devicesMap.set(key, dev)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load devices', e)
|
||||
}
|
||||
},
|
||||
async startMonitoring() {
|
||||
this.subscription = await DeviceRealtimeService.subscribeAllDevices({
|
||||
onData: (data: DeviceBatchItem) => {
|
||||
this.handleDeviceData(data)
|
||||
},
|
||||
onError: (err: any) => {
|
||||
console.error('Device monitoring error', err)
|
||||
}
|
||||
})
|
||||
},
|
||||
stopMonitoring() {
|
||||
if (this.subscription != null) {
|
||||
this.subscription!.dispose()
|
||||
this.subscription = null
|
||||
}
|
||||
},
|
||||
handleDeviceData(data: DeviceBatchItem) {
|
||||
// 必须有RSSI信息才视为有效心跳
|
||||
if (data.rssi == 0) return
|
||||
|
||||
// 使用watch_id (data.id) 作为唯一标识
|
||||
const watchId = data.id
|
||||
const key = `nid:${watchId}`
|
||||
|
||||
// 优先使用数据中的接收时间,否则使用当前时间
|
||||
const updateTime = data.recvtime > 0 ? data.recvtime : Date.now()
|
||||
|
||||
let device = this.devicesMap.get(key)
|
||||
|
||||
// 过滤无效的 studentName
|
||||
const validStudentName = (data.studentName != '' && data.studentName != 'Unknown') ? data.studentName : ''
|
||||
|
||||
if (device == null) {
|
||||
device = {
|
||||
uniqueKey: key,
|
||||
dbId: null,
|
||||
numericId: watchId,
|
||||
name: validStudentName != '' ? validStudentName : `Device ${watchId}`,
|
||||
mac: '',
|
||||
isOnline: true,
|
||||
lastUpdateTime: updateTime,
|
||||
data: data
|
||||
}
|
||||
this.devicesMap.set(key, device)
|
||||
} else {
|
||||
device.isOnline = true
|
||||
device.lastUpdateTime = updateTime
|
||||
device.data = data
|
||||
// 只有当 studentName 有效时才更新名称,避免覆盖 DB 中的名称
|
||||
if (validStudentName != '') {
|
||||
device.name = validStudentName
|
||||
}
|
||||
}
|
||||
},
|
||||
checkOnlineStatus() {
|
||||
this.devicesMap.forEach((device) => {
|
||||
if (device.lastUpdateTime > 0) {
|
||||
// 180秒内视为在线
|
||||
device.isOnline = (this.now - device.lastUpdateTime) < 180000
|
||||
}
|
||||
})
|
||||
},
|
||||
formatTime(ts: number): string {
|
||||
if (ts == 0) return '-'
|
||||
return new Date(ts).toLocaleTimeString()
|
||||
},
|
||||
switchPage() {
|
||||
uni.redirectTo({
|
||||
url: '/pages/test/ap_monitor'
|
||||
})
|
||||
},
|
||||
openDeviceTest(device: DisplayDevice) {
|
||||
if (device.numericId > 0) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/test/device_monitor?id=${device.numericId}`
|
||||
})
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '该设备无数字ID,无法测试',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.page-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: #fff;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
margin: 0 5px;
|
||||
background-color: #f5f5f5;
|
||||
color: #666;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background-color: #007aff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 15px 20px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.grid-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.card-online {
|
||||
border-color: #4caf50;
|
||||
}
|
||||
|
||||
.card-offline {
|
||||
border-color: #e0e0e0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 5px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.badge-online {
|
||||
background-color: #e8f5e9;
|
||||
}
|
||||
|
||||
.badge-offline {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.badge-text {
|
||||
font-size: 10px;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.badge-offline .badge-text {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: #d32f2f;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user