Initial commit of akmon project
This commit is contained in:
540
pages/sense/detail.uvue
Normal file
540
pages/sense/detail.uvue
Normal file
@@ -0,0 +1,540 @@
|
||||
<template>
|
||||
<view class="detail-container">
|
||||
<!-- 头部导航 -->
|
||||
<view class="header">
|
||||
<button class="back-btn" @click="goBack">
|
||||
<text class="back-icon">←</text>
|
||||
</button>
|
||||
<text class="title">传感器详情</text>
|
||||
<view class="header-actions">
|
||||
<button class="export-btn" @click="exportData">导出</button>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 基本信息卡片 -->
|
||||
<view class="info-card" v-if="measurementData !== null">
|
||||
<view class="card-header">
|
||||
<text class="card-title">{{ getTypeLabel(currentMeasurement?.measurement_type ?? '') }}</text>
|
||||
<text class="measurement-time">{{ formatDateTime(currentMeasurement?.measured_at ?? '') }}</text>
|
||||
</view>
|
||||
|
||||
<view class="value-display">
|
||||
<text class="main-value">{{ formatMainValue() }}</text>
|
||||
<text class="unit">{{ currentMeasurement?.unit ?? '' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="metadata">
|
||||
<view class="meta-item">
|
||||
<text class="meta-label">设备ID:</text>
|
||||
<text class="meta-value">{{ currentMeasurement?.device_id ?? '--' }}</text>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<text class="meta-label">用户ID:</text>
|
||||
<text class="meta-value">{{ currentMeasurement?.user_id ?? '--' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 详细数据卡片 -->
|
||||
<view class="raw-data-card" v-if="measurementData !== null">
|
||||
<text class="card-title">详细数据</text>
|
||||
<view class="raw-data-content">
|
||||
<view class="data-item" v-for="(item, index) in rawDataItems" :key="index">
|
||||
<text class="data-label">{{ item.label }}:</text>
|
||||
<text class="data-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 历史趋势图表 -->
|
||||
<view class="trend-card">
|
||||
<text class="card-title">历史趋势 (最近24小时)</text>
|
||||
<ak-charts :option="trendOption" :canvas-id="'trend-chart'" class="trend-chart" />
|
||||
</view>
|
||||
|
||||
<!-- 相关建议 -->
|
||||
<view class="suggestion-card" v-if="suggestions.length > 0">
|
||||
<text class="card-title">健康建议</text>
|
||||
<view class="suggestions-list">
|
||||
<view class="suggestion-item" v-for="(suggestion, index) in suggestions" :key="index">
|
||||
<text class="suggestion-icon">💡</text>
|
||||
<text class="suggestion-text">{{ suggestion }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, computed } from 'vue'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import { SenseDataService } from './senseDataService.uts'
|
||||
import type { SensorMeasurement } from './types.uts'
|
||||
import { setClipboardData, SetClipboardDataOption } from '@/uni_modules/lime-clipboard'
|
||||
|
||||
const measurementData = ref<SensorMeasurement | null>(null)
|
||||
const rawDataItems = ref<Array<UTSJSONObject>>([])
|
||||
const trendOption = ref<UTSJSONObject>(new UTSJSONObject())
|
||||
const suggestions = ref<Array<string>>([])
|
||||
const isLoading = ref<boolean>(false)
|
||||
const error = ref<string>('')
|
||||
|
||||
// 页面参数
|
||||
const measurementId = ref<string>('')
|
||||
|
||||
// 计算属性 - 用于模板访问,避免智能转换问题
|
||||
const currentMeasurement = computed(() => {
|
||||
return measurementData.value
|
||||
})
|
||||
function formatMainValue() : string {
|
||||
if (measurementData.value === null) return '--'
|
||||
// Fix Smart cast issue by assigning to local variable
|
||||
const measurement = measurementData.value
|
||||
const rawData = measurement?.raw_data
|
||||
const type = measurement?.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)
|
||||
} else if (type === 'bp') {
|
||||
const systolic = rawData.getNumber('systolic') ?? 0
|
||||
const diastolic = rawData.getNumber('diastolic') ?? 0
|
||||
return `${systolic}/${diastolic}`
|
||||
}
|
||||
|
||||
return '--'
|
||||
}
|
||||
|
||||
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 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', '#FFA07A')
|
||||
colors.set('bp', '#98D8C8')
|
||||
colors.set('stride', '#F7DC6F')
|
||||
|
||||
return colors.get(type) ?? '#95A5A6'
|
||||
}
|
||||
|
||||
function formatDateTime(timeStr : string) : string {
|
||||
if (timeStr === '') return '--'
|
||||
|
||||
const time = new Date(timeStr)
|
||||
const year = time.getFullYear()
|
||||
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')
|
||||
const second = time.getSeconds().toString().padStart(2, '0')
|
||||
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
||||
}
|
||||
function addDataItem(items : Array<UTSJSONObject>, label : string, value : string, unit : string) {
|
||||
const item = new UTSJSONObject()
|
||||
item.set('label', label)
|
||||
item.set('value', value + (unit !== '' ? ' ' + unit : ''))
|
||||
items.push(item)
|
||||
}
|
||||
function updateTrendChart(data : Array<SensorMeasurement>, type : string) {
|
||||
const chartData : Array<number> = []
|
||||
const chartLabels : Array<string> = []
|
||||
|
||||
for (let i : Int = 0; i < data.length; i++) {
|
||||
const item = data[i]
|
||||
const rawData = item.raw_data
|
||||
if (rawData !== null) {
|
||||
let value : number = 0
|
||||
|
||||
// 根据类型提取数值
|
||||
if (type === 'heart_rate') {
|
||||
value = rawData.getNumber('bpm') ?? 0
|
||||
} else if (type === 'steps') {
|
||||
value = rawData.getNumber('count') ?? 0
|
||||
} else if (type === 'spo2') {
|
||||
value = rawData.getNumber('spo2') ?? 0
|
||||
} else if (type === 'temp') {
|
||||
value = rawData.getNumber('temp') ?? 0
|
||||
} else if (type === 'bp') {
|
||||
value = rawData.getNumber('systolic') ?? 0
|
||||
}
|
||||
|
||||
chartData.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', chartData)
|
||||
option.set('labels', chartLabels)
|
||||
option.set('color', getChartColor(type))
|
||||
|
||||
trendOption.value = option
|
||||
}
|
||||
|
||||
async function loadTrendData() {
|
||||
if (supa === null || measurementData.value === null) return
|
||||
// Fix Smart cast issue by assigning to local variable
|
||||
const measurement = measurementData.value
|
||||
const type = measurement?.measurement_type ?? ''
|
||||
const deviceId = measurement?.device_id ?? ''
|
||||
const userId = measurement?.user_id ?? ''
|
||||
|
||||
try {
|
||||
// 获取最近24小时的同类型数据
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
const result = await supa.from('ss_sensor_measurements')
|
||||
.eq('measurement_type', type)
|
||||
.eq('device_id', deviceId)
|
||||
.eq('user_id', userId)
|
||||
.gte('measured_at', yesterday.toISOString())
|
||||
.order('measured_at', { ascending: true })
|
||||
.limit(50)
|
||||
.executeAs<Array<SensorMeasurement>>()
|
||||
if (result.data !== null && Array.isArray(result.data)) {
|
||||
const trendData = result.data as Array<SensorMeasurement>
|
||||
updateTrendChart(trendData, type)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('加载趋势数据失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function generateSuggestions() {
|
||||
if (measurementData.value === null) return
|
||||
// Fix Smart cast issue by assigning to local variable
|
||||
const measurement = measurementData.value
|
||||
const type = measurement?.measurement_type ?? ''
|
||||
const rawData = measurement?.raw_data
|
||||
if (rawData === null) return
|
||||
|
||||
const suggestionList : Array<string> = []
|
||||
|
||||
// 根据不同指标生成建议
|
||||
if (type === 'heart_rate') {
|
||||
const bpm = rawData.getNumber('bpm') ?? 0
|
||||
if (bpm < 60) {
|
||||
suggestionList.push('心率偏低,建议适量运动增强心肺功能')
|
||||
} else if (bpm > 100) {
|
||||
suggestionList.push('心率偏高,建议放松休息,避免剧烈运动')
|
||||
} else {
|
||||
suggestionList.push('心率正常,继续保持良好的生活习惯')
|
||||
}
|
||||
} else if (type === 'spo2') {
|
||||
const spo2 = rawData.getNumber('spo2') ?? 0
|
||||
if (spo2 < 95) {
|
||||
suggestionList.push('血氧偏低,建议深呼吸或到空气清新的地方')
|
||||
} else {
|
||||
suggestionList.push('血氧正常,保持良好的呼吸习惯')
|
||||
}
|
||||
} else if (type === 'temp') {
|
||||
const temp = rawData.getNumber('temp') ?? 0
|
||||
if (temp > 37.3) {
|
||||
suggestionList.push('体温偏高,注意休息并观察症状')
|
||||
} else if (temp < 36.0) {
|
||||
suggestionList.push('体温偏低,注意保暖')
|
||||
}
|
||||
}
|
||||
|
||||
suggestions.value = suggestionList
|
||||
}
|
||||
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
function exportData() {
|
||||
if (measurementData.value === null) return
|
||||
|
||||
// Fix Smart cast issue by assigning to local variable
|
||||
const measurement = measurementData.value
|
||||
// 构建导出数据
|
||||
const exportData = new UTSJSONObject()
|
||||
exportData.set('measurement_id', measurement?.id ?? '')
|
||||
exportData.set('type', measurement?.measurement_type ?? '')
|
||||
exportData.set('measured_at', measurement?.measured_at ?? '')
|
||||
exportData.set('raw_data', measurement?.raw_data)
|
||||
exportData.set('export_time', new Date().toISOString())
|
||||
// 转换为JSON字符串
|
||||
const jsonStr = JSON.stringify(exportData)
|
||||
|
||||
// 复制到剪贴板
|
||||
setClipboardData({
|
||||
data: jsonStr,
|
||||
success: (res : UniError) => {
|
||||
uni.showToast({
|
||||
title: '数据已复制到剪贴板',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
} as SetClipboardDataOption)
|
||||
} function parseRawData() {
|
||||
if (measurementData.value === null) return
|
||||
|
||||
// Fix smart cast issue by using local variable with safe navigation
|
||||
const measurement = measurementData.value
|
||||
const rawData = measurement?.raw_data
|
||||
if (rawData === null) return
|
||||
|
||||
const items : Array<UTSJSONObject> = []
|
||||
const type = measurement?.measurement_type ?? ''
|
||||
|
||||
// 根据不同类型解析原始数据
|
||||
if (type === 'heart_rate') {
|
||||
addDataItem(items, '心率', rawData.getNumber('bpm')?.toString() ?? '--', 'bpm')
|
||||
addDataItem(items, 'RR间期', rawData.getNumber('rr_interval')?.toString() ?? '--', 'ms')
|
||||
addDataItem(items, '心率变异性', rawData.getNumber('hrv')?.toString() ?? '--', 'ms')
|
||||
} else if (type === 'steps') {
|
||||
addDataItem(items, '步数', rawData.getNumber('count')?.toString() ?? '--', '步')
|
||||
addDataItem(items, '距离', rawData.getNumber('distance')?.toString() ?? '--', 'm')
|
||||
addDataItem(items, '卡路里', rawData.getNumber('calories')?.toString() ?? '--', 'kcal')
|
||||
} else if (type === 'spo2') {
|
||||
addDataItem(items, '血氧饱和度', rawData.getNumber('spo2')?.toString() ?? '--', '%')
|
||||
addDataItem(items, '灌注指数', rawData.getNumber('pi')?.toString() ?? '--', '%')
|
||||
} else if (type === 'temp') {
|
||||
addDataItem(items, '体温', rawData.getNumber('temp')?.toString() ?? '--', '°C')
|
||||
addDataItem(items, '环境温度', rawData.getNumber('ambient_temp')?.toString() ?? '--', '°C')
|
||||
} else if (type === 'bp') {
|
||||
addDataItem(items, '收缩压', rawData.getNumber('systolic')?.toString() ?? '--', 'mmHg')
|
||||
addDataItem(items, '舒张压', rawData.getNumber('diastolic')?.toString() ?? '--', 'mmHg')
|
||||
addDataItem(items, '脉压', rawData.getNumber('pulse_pressure')?.toString() ?? '--', 'mmHg')
|
||||
addDataItem(items, '平均动脉压', rawData.getNumber('map')?.toString() ?? '--', 'mmHg')
|
||||
}
|
||||
|
||||
rawDataItems.value = items
|
||||
}
|
||||
|
||||
async function loadMeasurementData() {
|
||||
if (measurementId.value === '') return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const response = await SenseDataService.getMeasurementById(measurementId.value)
|
||||
if (response.status >= 200 && response.status < 300 && response.data !== null) {
|
||||
measurementData.value = response.data as SensorMeasurement
|
||||
parseRawData()
|
||||
loadTrendData()
|
||||
generateSuggestions()
|
||||
} else {
|
||||
error.value = '数据加载失败'
|
||||
uni.showToast({
|
||||
title: '数据加载失败',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = '数据加载失败: ' + (typeof e === 'string' ? e : e?.message ?? '未知错误')
|
||||
console.log('加载测量数据失败:', e)
|
||||
uni.showToast({
|
||||
title: '数据加载失败',
|
||||
icon: 'error'
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
// 生命周期
|
||||
onLoad((options) => {
|
||||
if (options["id"] !== null) {
|
||||
measurementId.value = options.getString("id") ?? ''
|
||||
loadMeasurementData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-container {
|
||||
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;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 12rpx;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 8rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
font-size: 32rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
padding: 16rpx 24rpx;
|
||||
background-color: #409EFF;
|
||||
color: #ffffff;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.info-card,
|
||||
.raw-data-card,
|
||||
.trend-card,
|
||||
.suggestion-card {
|
||||
margin-bottom: 20rpx;
|
||||
padding: 24rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.measurement-time {
|
||||
font-size: 24rpx;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.value-display {
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.main-value {
|
||||
font-size: 80rpx;
|
||||
font-weight: bold;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 32rpx;
|
||||
color: #666666;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
border-top: 2rpx solid #f0f0f0;
|
||||
padding-top: 20rpx;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 26rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.raw-data-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.data-label {
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.data-value {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
height: 400rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.suggestions-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
font-size: 32rpx;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.suggestion-text {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user