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

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>