Initial commit of akmon project

This commit is contained in:
2026-01-20 08:04:15 +08:00
commit 77a2bab985
1309 changed files with 343305 additions and 0 deletions

View 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>