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