Files
akmon/pages/test/multi_device_monitor.uvue
2026-01-20 08:04:15 +08:00

416 lines
10 KiB
Plaintext
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.
<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>