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

951 lines
24 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> <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>