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

951
pages/sense/index.uvue Normal file
View File

@@ -0,0 +1,951 @@
<template> <scroll-view direction="vertical" class="sense-container">
<!-- 头部导航 -->
<view class="header">
<text class="title">传感器数据监控</text>
<view class="header-actions">
<button class="refresh-btn" @click="refreshData">刷新</button>
<button class="analyze-btn" @click="analyzeData">AI分析</button>
</view>
</view>
<!-- 页面导航菜单 -->
<view class="nav-menu">
<view class="nav-info" v-if="deviceInfo !== null">
<text class="nav-device-info">当前设备: {{ deviceName }}</text>
<text class="nav-device-status" :class="deviceStatusClass">{{ deviceStatus }}</text>
</view>
<view class="nav-tabs">
<button class="nav-tab active" @click="navigateToPage('index')">
<text class="nav-icon">📊</text>
<text class="nav-text">数据监控</text>
</button>
<button class="nav-tab" @click="navigateToPage('analysis')">
<text class="nav-icon">📈</text>
<text class="nav-text">数据分析</text>
</button>
<button class="nav-tab" @click="navigateToPage('devices')">
<text class="nav-icon">📱</text>
<text class="nav-text">设备管理</text>
</button>
<button class="nav-tab" @click="navigateToPage('simulator')">
<text class="nav-icon">🔧</text>
<text class="nav-text">数据模拟</text>
</button>
<button class="nav-tab" @click="navigateToPage('settings')">
<text class="nav-icon">⚙️</text>
<text class="nav-text">设置</text>
</button>
</view>
</view>
<!-- 设备状态卡片 -->
<view class="device-card">
<text class="device-title">设备状态</text>
<view class="device-info" v-if="deviceInfo !== null">
<text class="device-name">{{ deviceName }}</text>
<text class="device-status" :class="deviceStatusClass">{{ deviceStatus }}</text>
</view>
</view>
<!-- 实时数据卡片 -->
<view class="realtime-card">
<text class="card-title">实时数据</text>
<view class="metrics-grid">
<view class="metric-item" v-for="(metric, index) in realtimeMetrics" :key="index">
<text class="metric-label">{{ metric.label }}</text>
<text class="metric-value">{{ metric.value }}</text>
<text class="metric-unit">{{ metric.unit }}</text>
</view>
</view>
</view>
<!-- 图表展示 -->
<view class="chart-card">
<text class="card-title">数据趋势</text>
<view class="chart-tabs">
<button class="tab-btn" :class="{ active: activeChartType == type }" v-for="(type, index) in chartTypes"
:key="index" @click="switchChart(type)">
{{ getChartTypeLabel(type) }}
</button>
</view>
<ak-charts :option="chartOption" :canvas-id="'sensor-chart'" class="chart-component" />
</view>
<!-- 历史记录列表 -->
<view class="history-card">
<view class="card-header">
<text class="card-title">最新记录</text>
<button class="load-more-btn" @click="loadMoreHistory">查看更多</button>
</view>
<scroll-view class="history-list" direction="vertical">
<view class="history-item" v-for="(item, index) in historyData" :key="index" @click="viewDetail(item)">
<view class="history-content">
<text class="history-type">{{ getTypeLabel(item.measurement_type ?? '') }}</text>
<text class="history-value">{{ formatValue(item) }}</text>
</view>
<text class="history-time">{{ formatTime(item.measured_at ?? '') }}</text>
</view>
<view v-if="historyData.length == 0" class="empty-history">
<text class="empty-text">暂无记录</text>
</view>
</scroll-view>
</view>
<!-- AI分析结果弹窗 -->
<view class="analysis-modal" v-if="showAnalysis" @click="closeAnalysis">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">AI分析结果</text>
<button class="close-btn" @click="closeAnalysis">×</button>
</view>
<view class="modal-body">
<text class="analysis-summary">{{ analysisSummary }}</text>
<view class="recommendations" v-if="recommendations.length > 0">
<text class="rec-title">建议:</text>
<text class="rec-item" v-for="(rec, index) in recommendations" :key="index">• {{ rec }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import akCharts from '@/uni_modules/ak-charts/components/ak-charts.uvue'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { SenseDataService, type SensorDataParams } from './senseDataService.uts'
import { state } from '@/utils/store.uts'
import supa from '@/components/supadb/aksupainstance.uts'
import type { SensorMeasurement, SensorAnalysisResult, DeviceInfo, ChartDataPoint } from './types.uts'
// 响应式数据
const deviceInfo = ref<DeviceInfo | null>(null)
const deviceStatus = ref<string>('离线')
const deviceStatusClass = ref<string>('status-offline')
const realtimeMetrics = ref<Array<UTSJSONObject>>([])
const historyData = ref<Array<SensorMeasurement>>([])
// 初始化 chartOption 为有效的图表配置
const initialChartOption = new UTSJSONObject()
initialChartOption.set('type', 'line')
initialChartOption.set('data', [] as number[])
initialChartOption.set('labels', [] as string[])
initialChartOption.set('color', '#1890ff')
const chartOption = ref<UTSJSONObject>(initialChartOption)
const activeChartType = ref<string>('heart_rate')
const showAnalysis = ref<boolean>(false)
const analysisSummary = ref<string>('')
const recommendations = ref<Array<string>>([])
const isLoading = ref<boolean>(false)
const error = ref<string>('')
// 实时订阅引用
let realtimeSubscription : any = null // 从 state 直接获取设备列表 - 使用computed确保响应性
const devices = computed<Array<DeviceInfo>>(() => state.deviceState.devices)
const currentDevice = computed<DeviceInfo | null>(() => state.deviceState.currentDevice) // 设备信息计算属性 - 避免smart cast问题
const deviceName = computed<string>(() => {
const device = deviceInfo.value
if (device != null) {
return device.device_name ?? '未知设备'
}
return '未知设备'
})// 常量
const chartTypes = ['heart_rate', 'steps', 'spo2', 'temp', 'bp']
let deviceId = '12345678-1234-5678-9abc-123456789012'
const userId = 'eed3824b-bba1-4309-8048-19d17367c084'
// 工具函数 - 定义在使用之前
function getTypeLabel(type : string) : string {
const labels = new Map<string, string>()
labels.set('heart_rate', '心率')
labels.set('steps', '步数')
labels.set('spo2', '血氧')
labels.set('temp', '体温')
labels.set('bp', '血压')
labels.set('stride', '步幅')
return labels.get(type) ?? type
}
function getChartTypeLabel(type : string) : string {
return getTypeLabel(type)
}
function getChartColor(type : string) : string {
const colors = new Map<string, string>()
colors.set('heart_rate', '#FF6B6B')
colors.set('steps', '#4ECDC4')
colors.set('spo2', '#45B7D1')
colors.set('temp', '#FFA726')
colors.set('bp', '#AB47BC')
return colors.get(type) ?? '#2196F3'
}
function formatValue(item : SensorMeasurement) : string {
const rawData = item.raw_data
console.log(rawData)
const type = item.measurement_type ?? ''
if (rawData == null) return '--'
if (type == 'heart_rate') {
const bpm = rawData.getNumber('bpm') ?? 0
return bpm.toString()
} else if (type == 'steps') {
const count = rawData.getNumber('count') ?? 0
return count.toString()
} else if (type == 'spo2') {
const spo2 = rawData.getNumber('spo2') ?? 0
return spo2.toString() + '%'
} else if (type == 'temp') {
const temp = rawData.getNumber('temp') ?? 0
return temp.toFixed(1) + '°C'
} else if (type == 'bp') {
const systolic = rawData.getNumber('systolic') ?? 0
const diastolic = rawData.getNumber('diastolic') ?? 0
return `${systolic}/${diastolic}`
}
console.log('should be not occur')
return '--'
}
function subscribeRealtime() {
if (supa == null) return
// 注意:当前的 aksupa 实现可能不支持实时订阅
// 这里简化为定期刷新数据
console.log('实时订阅功能暂不可用,将使用定期刷新')
// 可以在这里添加定期刷新逻辑
// setInterval(() => {
// loadHistoryData()
// }, 30000) // 每30秒刷新一次
}
function updateDeviceStatus() {
const currentDeviceInfo = deviceInfo.value
if (currentDeviceInfo == null) return
const status = currentDeviceInfo.status ?? 'offline'
if (status == 'online') {
deviceStatus.value = '在线'
deviceStatusClass.value = 'status-online'
} else {
deviceStatus.value = '离线'
deviceStatusClass.value = 'status-offline'
}
}
function updateRealtimeMetrics() {
const metrics : Array<UTSJSONObject> = []
// 从最新数据中提取各类指标
const typeMap = new Map<string, SensorMeasurement>()
for (let i : Int = 0; i < historyData.value.length; i++) {
const item = historyData.value[i]
const type = item.measurement_type ?? ''
if (type !== '' && !typeMap.has(type)) {
typeMap.set(type, item)
}
}
// 构建指标数组
typeMap.forEach((value : SensorMeasurement, key : string) => {
const metric = new UTSJSONObject()
metric.set('label', getTypeLabel(key))
metric.set('value', formatValue(value))
metric.set('unit', value.unit ?? '')
metrics.push(metric)
})
console.log(historyData)
realtimeMetrics.value = metrics
}
function updateChartWithData(chartData : Array<SensorMeasurement>) {
const chartValues : Array<number> = []
const chartLabels : Array<string> = []
// 过滤当前图表类型的数据
const filteredData : Array<SensorMeasurement> = []
for (let i : Int = 0; i < chartData.length; i++) {
const item = chartData[i]
const type = item.measurement_type ?? ''
if (type == activeChartType.value) {
filteredData.push(item)
}
}
// 取最近20个数据点
const recentData = filteredData.slice(0, 20).reverse()
for (let i : Int = 0; i < recentData.length; i++) {
const item = recentData[i]
const rawData = item.raw_data
if (rawData !== null) {
let value : number = 0
// 根据数据类型提取数值
if (activeChartType.value == 'heart_rate') {
value = rawData.getNumber('bpm') ?? 0
} else if (activeChartType.value == 'steps') {
value = rawData.getNumber('count') ?? 0
} else if (activeChartType.value == 'spo2') {
value = rawData.getNumber('spo2') ?? 0
} else if (activeChartType.value == 'temp') {
value = rawData.getNumber('temp') ?? 0
} else if (activeChartType.value == 'bp') {
value = rawData.getNumber('systolic') ?? 0
}
chartValues.push(value)
// 格式化时间标签
const timeStr = item.measured_at ?? ''
const time = new Date(timeStr)
const label = time.getHours().toString().padStart(2, '0') + ':' +
time.getMinutes().toString().padStart(2, '0')
chartLabels.push(label)
}
}
// 更新图表配置
const option = new UTSJSONObject()
option.set('type', 'line')
option.set('data', chartValues)
option.set('labels', chartLabels)
option.set('color', getChartColor(activeChartType.value))
chartOption.value = option
}
function updateChart() {
updateChartWithData(historyData.value)
}
async function analyzeData() {
try {
// 调用分析服务获取分析结果
const response = await SenseDataService.getAnalysisResults(userId, 'ai_analysis')
if (response.status >= 200 && response.status < 300 && response.data !== null && Array.isArray(response.data)) {
const dataArray = response.data as Array<any>
if (dataArray.length > 0) {
const analysis = dataArray[0] as UTSJSONObject // 获取最新的分析结果
analysisSummary.value = analysis.getString('summary') ?? '分析完成'
const recommArray = analysis.getArray('recommendations')
recommendations.value = Array.isArray(recommArray) ? recommArray as Array<string> : []
showAnalysis.value = true
} else {
// 如果没有现成的分析结果,显示默认信息
analysisSummary.value = '暂无分析数据,请稍后重试'
recommendations.value = ['建议定期监测健康数据', '保持良好的作息习惯', '如有异常及时就医']
showAnalysis.value = true
}
} else {
}
} catch (e) {
console.log('AI分析失败:', e)
analysisSummary.value = '分析服务暂时不可用,请稍后重试'
recommendations.value = []
showAnalysis.value = true
}
}
function viewDetail(item : SensorMeasurement) {
// 跳转到详情页面
const id = item.id ?? ''
uni.navigateTo({
url: `/pages/sense/detail?id=${id}`
})
}
function closeAnalysis() {
showAnalysis.value = false
}
async function loadDeviceInfo() {
isLoading.value = true
error.value = ''
try {
const response = await SenseDataService.getDeviceById(deviceId)
console.log(response)
if (response.status >= 200 && response.status < 300 && Array.isArray(response.data)) {
const dataArray = response.data as Array<any>
if (dataArray.length > 0) {
deviceInfo.value = dataArray[0] as DeviceInfo
updateDeviceStatus()
} else {
error.value = '加载设备信息失败'
}
} else {
error.value = '加载设备信息失败'
}
} catch (e) {
error.value = '加载设备信息失败: ' + (typeof e == 'string' ? e : e?.message ?? '未知错误')
console.log('加载设备信息失败:', e)
} finally {
isLoading.value = false
}
}
// 专门为图表加载少量历史数据
async function loadChartData() {
try {
const params : SensorDataParams = {
device_id: deviceId,
user_id: userId,
limit: 100, // 为图表获取10条数据用于趋势显示
offset: 0
}
const response = await SenseDataService.getMeasurements(params)
if (response.status >= 200 && response.status < 300 && response.data !== null) {
console.log(response)
// 将图表数据单独存储,不覆盖历史记录列表
const chartHistoryData = response.data as Array<SensorMeasurement>
updateChartWithData(chartHistoryData)
}
} catch (e) {
console.log('加载图表数据失败:', e)
// 如果图表数据加载失败,使用现有的历史数据
updateChart()
}
}
async function loadHistoryData() {
isLoading.value = true
error.value = ''
try {
const params : SensorDataParams = {
device_id: deviceId,
user_id: userId,
limit: 1, // 设置为 1会自动使用 single() 方法优化查询
offset: 0
}
const response = await SenseDataService.getMeasurements(params)
if (response.status >= 200 && response.status < 300 && response.data !== null) {
historyData.value = response.data as Array<SensorMeasurement>
updateRealtimeMetrics()
// 图表需要更多数据,单独加载
await loadChartData()
} else {
error.value = '加载历史数据失败'
historyData.value = []
}
} catch (e) {
error.value = '加载历史数据失败: ' + (typeof e == 'string' ? e : e?.message ?? '未知错误')
console.log('加载历史数据失败:', e)
historyData.value = []
} finally {
isLoading.value = false
}
}
// 加载更多历史记录
async function loadMoreHistory() {
isLoading.value = true
try {
const params : SensorDataParams = {
device_id: deviceId,
user_id: userId,
limit: 50, // 加载更多历史数据
offset: 0
}
const response = await SenseDataService.getMeasurements(params)
if (response.status >= 200 && response.status < 300 && response.data !== null) {
historyData.value = response.data as Array<SensorMeasurement>
} else {
error.value = '加载更多历史数据失败'
}
} catch (e) {
error.value = '加载更多历史数据失败: ' + (typeof e == 'string' ? e : e?.message ?? '未知错误')
console.log('加载更多历史数据失败:', e)
} finally {
isLoading.value = false
}
}
function switchChart(type : string) {
activeChartType.value = type
// 重新加载图表数据以显示新类型的趋势
loadChartData()
}
// 刷新数据函数
function refreshData() {
loadDeviceInfo()
loadHistoryData()
}
onMounted(() => { // 获取设备ID参数
const pages = getCurrentPages()
if (pages.length > 0) {
const currentPage = pages[pages.length - 1]
const options = currentPage.options
const deviceIdParam = options?.device_id ?? ''
if (deviceIdParam !== '') {
deviceId = deviceIdParam
}
}
loadDeviceInfo()
loadHistoryData()
})
onUnmounted(() => {
// 清理资源
})
function formatTime(timeStr : string) : string {
if (timeStr == '') return '--'
const time = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - time.getTime()
if (diff < 60000) { // 1分钟内
return '刚刚'
} else if (diff < 3600000) { // 1小时内
const minutes = Math.floor(diff / 60000)
return `${minutes}分钟前`
} else if (diff < 86400000) { // 24小时内
const hours = Math.floor(diff / 3600000)
return `${hours}小时前`
} else {
const month = (time.getMonth() + 1).toString().padStart(2, '0')
const day = time.getDate().toString().padStart(2, '0')
const hour = time.getHours().toString().padStart(2, '0')
const minute = time.getMinutes().toString().padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
} // 导航方法
function navigateToPage(pageName : string) {
// 验证必要参数
if (deviceId == '' || userId == '') {
uni.showToast({
title: '缺少必要参数',
icon: 'none'
})
console.log('导航失败: 缺少deviceId或userId', { deviceId, userId })
return
}
let url = ''
switch (pageName) {
case 'analysis':
// 分析页面需要用户ID和可选的设备ID
url = `/pages/sense/analysis?user_id=${userId}&device_id=${deviceId}`
break
case 'devices':
// 设备管理页面不需要特定参数但传递用户ID以便管理
url = `/pages/sense/devices?user_id=${userId}`
break
case 'simulator':
// 模拟器页面需要设备ID和用户ID
url = `/pages/sense/simulator?device_id=${deviceId}&user_id=${userId}`
break
case 'settings':
// 设置页面需要设备ID和用户ID
url = `/pages/sense/settings?device_id=${deviceId}&user_id=${userId}`
break
case 'index':
// 当前页面,不需要跳转
return
default:
console.log('未知页面:', pageName)
uni.showToast({
title: '未知页面',
icon: 'none'
})
return
}
console.log('准备导航到:', url)
uni.navigateTo({
url: url,
success: (res) => {
console.log('导航成功:', res)
},
fail: (error) => {
console.log('导航失败:', error)
uni.showToast({
title: '页面跳转失败',
icon: 'none'
})
}
})
}
</script>
<style scoped>
.sense-container {
display: flex;
flex: 1;
padding: 20rpx;
background-color: #f5f5f5;
}
.header {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding: 20rpx;
background-color: #ffffff;
border-radius: 12rpx;
}
.title {
font-size: 32rpx;
font-weight: bold;
color: #333333;
}
.header-actions {
flex-direction: row;
}
.refresh-btn,
.analyze-btn {
padding: 16rpx 24rpx;
margin-left: 16rpx;
border-radius: 8rpx;
font-size: 28rpx;
}
.refresh-btn {
background-color: #f0f0f0;
color: #666666;
}
.analyze-btn {
background-color: #409EFF;
color: #ffffff;
}
.device-card,
.realtime-card,
.chart-card,
.history-card {
margin-bottom: 20rpx;
padding: 24rpx;
background-color: #ffffff;
border-radius: 12rpx;
}
.device-title,
.card-title {
font-size: 30rpx;
font-weight: bold;
color: #333333;
margin-bottom: 16rpx;
}
.card-header {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.load-more-btn {
padding: 12rpx 20rpx;
background-color: #409EFF;
color: #ffffff;
border-radius: 6rpx;
font-size: 24rpx;
border: none;
}
.device-info {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.device-name {
font-size: 28rpx;
color: #666666;
}
.device-status {
font-size: 26rpx;
padding: 8rpx 16rpx;
border-radius: 16rpx;
}
.status-online {
background-color: #E8F5E8;
color: #52C41A;
}
.status-offline {
background-color: #FFF1F0;
color: #FF4D4F;
}
.metrics-grid {
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
.metric-item {
width: 48%;
padding: 20rpx;
margin-bottom: 16rpx;
background-color: #f8f9fa;
border-radius: 8rpx;
align-items: center;
}
.metric-label {
font-size: 24rpx;
color: #999999;
margin-bottom: 8rpx;
}
.metric-value {
font-size: 36rpx;
font-weight: bold;
color: #333333;
}
.metric-unit {
font-size: 22rpx;
color: #666666;
margin-top: 4rpx;
}
.chart-tabs {
flex-direction: row;
margin-bottom: 20rpx;
border-bottom: 2rpx solid #f0f0f0;
}
.tab-btn {
flex: 1;
padding: 20rpx;
background-color: transparent;
border: none;
color: #666666;
font-size: 26rpx;
}
.tab-btn.active {
color: #409EFF;
border-bottom: 4rpx solid #409EFF;
}
.chart-component {
height: 400rpx;
}
.history-list {
max-height: 600rpx;
}
.history-item {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.history-content {
flex: 1;
}
.history-type {
font-size: 28rpx;
color: #333333;
margin-bottom: 8rpx;
}
.history-value {
font-size: 32rpx;
font-weight: bold;
color: #409EFF;
}
.history-time {
font-size: 24rpx;
color: #999999;
}
.empty-history {
padding: 60rpx 0;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 26rpx;
color: #999999;
}
.analysis-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
width: 80%;
max-height: 70%;
background-color: #ffffff;
border-radius: 16rpx;
padding: 32rpx;
}
.modal-header {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
border-bottom: 2rpx solid #f0f0f0;
padding-bottom: 16rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
color: #333333;
}
.close-btn {
width: 60rpx;
height: 60rpx;
border-radius: 30rpx;
background-color: #f0f0f0;
color: #666666;
font-size: 36rpx;
border: none;
justify-content: center;
align-items: center;
}
.modal-body {
flex: 1;
}
.analysis-summary {
font-size: 28rpx;
color: #333333;
line-height: 1.6;
margin-bottom: 24rpx;
}
.recommendations {
background-color: #f8f9fa;
padding: 20rpx;
border-radius: 8rpx;
}
.rec-title {
font-size: 26rpx;
font-weight: bold;
color: #333333;
margin-bottom: 12rpx;
}
.rec-item {
font-size: 24rpx;
color: #666666;
line-height: 1.5;
margin-bottom: 8rpx;
}
/* 导航菜单样式 */
.nav-menu {
margin-bottom: 20rpx;
background-color: #ffffff;
border-radius: 12rpx;
padding: 16rpx;
}
.nav-info {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 12rpx 16rpx;
margin-bottom: 12rpx;
background-color: #f8f9fa;
border-radius: 8rpx;
}
.nav-device-info {
font-size: 24rpx;
color: #666666;
}
.nav-device-status {
font-size: 22rpx;
font-weight: bold;
padding: 4rpx 12rpx;
border-radius: 12rpx;
}
.nav-device-status.status-online {
background-color: #d4edda;
color: #155724;
}
.nav-device-status.status-offline {
background-color: #f8d7da;
color: #721c24;
}
.nav-tabs {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.nav-tab {
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16rpx 8rpx;
margin: 0 4rpx;
border-radius: 8rpx;
background-color: transparent;
border: none;
transition: all 0.3s ease;
}
.nav-tab.active {
background-color: #007AFF;
}
.nav-tab:hover {
background-color: #f0f0f0;
}
.nav-tab.active:hover {
background-color: #0056CC;
}
.nav-icon {
font-size: 32rpx;
margin-bottom: 8rpx;
color: #666666;
}
.nav-tab.active .nav-icon {
color: #ffffff;
}
.nav-text {
font-size: 22rpx;
color: #666666;
text-align: center;
}
.nav-tab.active .nav-text {
color: #ffffff;
font-weight: bold;
}
</style>