540 lines
15 KiB
Plaintext
540 lines
15 KiB
Plaintext
<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> |