Initial commit of akmon project
This commit is contained in:
249
pages/sense/DEVICE_STORE_GUIDE.md
Normal file
249
pages/sense/DEVICE_STORE_GUIDE.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# 设备状态管理 (Device Store) 使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
为了避免在不同模块中重复请求设备数据,我们将设备信息集中管理在全局状态中。这样可以:
|
||||
|
||||
- ✅ **提高性能**:避免重复的网络请求
|
||||
- ✅ **数据一致性**:所有模块共享同一份设备数据
|
||||
- ✅ **实时更新**:设备状态变更时,所有相关UI自动更新
|
||||
- ✅ **缓存机制**:智能缓存,5分钟内不重复请求
|
||||
- ✅ **简化代码**:统一的设备操作API
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 设备状态管理
|
||||
|
||||
```typescript
|
||||
// 设备状态类型
|
||||
export type DeviceState = {
|
||||
devices: Array<DeviceInfo> // 设备列表
|
||||
currentDevice: DeviceInfo | null // 当前选中设备
|
||||
isLoading: boolean // 加载状态
|
||||
lastUpdated: number | null // 最后更新时间
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 主要API方法
|
||||
|
||||
#### 获取设备信息
|
||||
```typescript
|
||||
const deviceStore = getDeviceStore()
|
||||
|
||||
// 获取所有设备
|
||||
const devices = deviceStore.getDevices()
|
||||
|
||||
// 获取当前设备
|
||||
const currentDevice = deviceStore.getCurrentDevice()
|
||||
|
||||
// 根据ID获取设备
|
||||
const device = deviceStore.getDeviceById('device_id')
|
||||
|
||||
// 获取在线设备
|
||||
const onlineDevices = deviceStore.getOnlineDevices()
|
||||
```
|
||||
|
||||
#### 设备操作
|
||||
```typescript
|
||||
// 加载设备列表(带缓存,不强制刷新)
|
||||
await deviceStore.refreshDevices()
|
||||
|
||||
// 强制刷新设备列表(忽略缓存)
|
||||
await deviceStore.loadDevices(true)
|
||||
|
||||
// 绑定新设备
|
||||
const success = await deviceStore.bindDevice(deviceData)
|
||||
|
||||
// 解绑设备
|
||||
const success = await deviceStore.unbindDevice(deviceId)
|
||||
|
||||
// 更新设备配置
|
||||
const success = await deviceStore.updateDevice(deviceId, configData)
|
||||
|
||||
// 设置当前设备
|
||||
deviceStore.setCurrentDevice(device)
|
||||
```
|
||||
|
||||
#### 状态查询
|
||||
```typescript
|
||||
// 获取加载状态
|
||||
const isLoading = deviceStore.isLoading()
|
||||
|
||||
// 获取最后更新时间
|
||||
const lastUpdated = deviceStore.getLastUpdated()
|
||||
```
|
||||
|
||||
## 在Vue组件中的使用
|
||||
|
||||
### 1. 基本使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view>
|
||||
<!-- 显示设备列表 -->
|
||||
<view v-if="isLoading">加载中...</view>
|
||||
<view v-else>
|
||||
<view v-for="device in devices" :key="device.id">
|
||||
{{ device.device_name }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { getDeviceStore } from '@/utils/store.uts'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const deviceStore = getDeviceStore()
|
||||
|
||||
// 响应式数据
|
||||
const devices = computed(() => deviceStore.getDevices())
|
||||
const isLoading = computed(() => deviceStore.isLoading())
|
||||
|
||||
// 页面挂载时加载设备
|
||||
onMounted(async () => {
|
||||
await deviceStore.loadDevices()
|
||||
})
|
||||
|
||||
return {
|
||||
devices,
|
||||
isLoading
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. 设备管理页面
|
||||
|
||||
```typescript
|
||||
// 设备管理操作
|
||||
const bindDevice = async (deviceData: UTSJSONObject) => {
|
||||
const success = await deviceStore.bindDevice(deviceData)
|
||||
if (success) {
|
||||
// 设备已自动添加到store,UI自动更新
|
||||
uni.showToast({ title: '绑定成功' })
|
||||
}
|
||||
}
|
||||
|
||||
const unbindDevice = async (deviceId: string) => {
|
||||
const success = await deviceStore.unbindDevice(deviceId)
|
||||
if (success) {
|
||||
// 设备已自动从store移除,UI自动更新
|
||||
uni.showToast({ title: '解绑成功' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 跨页面设备状态共享
|
||||
|
||||
```typescript
|
||||
// 页面A:选择设备
|
||||
const selectDevice = (device: DeviceInfo) => {
|
||||
deviceStore.setCurrentDevice(device)
|
||||
uni.navigateTo({
|
||||
url: `/pages/sense/detail?device_id=${device.id}`
|
||||
})
|
||||
}
|
||||
|
||||
// 页面B:使用选中的设备
|
||||
const currentDevice = computed(() => deviceStore.getCurrentDevice())
|
||||
// currentDevice 会自动包含页面A选择的设备信息
|
||||
```
|
||||
|
||||
## 缓存机制
|
||||
|
||||
设备数据具有智能缓存机制:
|
||||
|
||||
- **自动缓存**:首次加载后,数据会被缓存5分钟
|
||||
- **避免重复请求**:5分钟内的 `loadDevices()` 调用直接返回缓存数据
|
||||
- **强制刷新**:使用 `loadDevices(true)` 可以强制从服务器重新获取
|
||||
- **实时更新**:设备操作(绑定、解绑、更新)会实时更新缓存
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
utils/
|
||||
store.uts # 主要的store文件,包含设备状态管理
|
||||
|
||||
pages/sense/
|
||||
devices.uvue # 设备管理页面,已更新使用store
|
||||
index.uvue # 传感器主页,已更新使用store
|
||||
detail.uvue # 设备详情页
|
||||
senseDataService.uts # 设备数据服务
|
||||
deviceStoreExample.uts # 使用示例和最佳实践
|
||||
insertExample.uts # 插入操作示例
|
||||
```
|
||||
|
||||
## 优势
|
||||
|
||||
### 性能优化
|
||||
- ✅ 减少网络请求:避免重复的设备列表请求
|
||||
- ✅ 智能缓存:5分钟内复用数据
|
||||
- ✅ 按需更新:只在必要时刷新数据
|
||||
|
||||
### 开发体验
|
||||
- ✅ 统一API:所有设备操作通过同一个接口
|
||||
- ✅ 类型安全:完整的TypeScript类型支持
|
||||
- ✅ 响应式:Vue的响应式系统自动更新UI
|
||||
- ✅ 错误处理:统一的错误处理和用户反馈
|
||||
|
||||
### 数据一致性
|
||||
- ✅ 单一数据源:所有模块共享同一份设备数据
|
||||
- ✅ 实时同步:设备状态变更立即反映到所有页面
|
||||
- ✅ 状态持久:页面间跳转不丢失设备状态
|
||||
|
||||
## 迁移指南
|
||||
|
||||
如果现有页面直接使用 `SenseDataService`:
|
||||
|
||||
### 原来的代码:
|
||||
```typescript
|
||||
// 旧方式:直接调用服务
|
||||
const result = await SenseDataService.getDevicesByUser(userId)
|
||||
if (result.success) {
|
||||
devices.value = result.data
|
||||
}
|
||||
```
|
||||
|
||||
### 更新后的代码:
|
||||
```typescript
|
||||
// 新方式:使用store
|
||||
const deviceStore = getDeviceStore()
|
||||
const devices = computed(() => deviceStore.getDevices())
|
||||
|
||||
onMounted(async () => {
|
||||
await deviceStore.refreshDevices() // 智能缓存加载
|
||||
})
|
||||
|
||||
// 强制刷新(忽略缓存)
|
||||
const forceRefresh = async () => {
|
||||
await deviceStore.loadDevices(true)
|
||||
}
|
||||
```
|
||||
|
||||
通过这种方式,设备状态管理变得更加高效和便于维护!
|
||||
|
||||
## API 参考
|
||||
|
||||
### 设备加载方法
|
||||
|
||||
由于UTS不支持匿名函数的默认参数,我们提供了两个加载方法:
|
||||
|
||||
```typescript
|
||||
// 1. 智能缓存加载(推荐用于页面初始化)
|
||||
await deviceStore.refreshDevices()
|
||||
// 等同于 loadDevices(false),会检查缓存
|
||||
|
||||
// 2. 明确指定是否强制刷新
|
||||
await deviceStore.loadDevices(true) // 强制刷新,忽略缓存
|
||||
await deviceStore.loadDevices(false) // 使用缓存(如果可用)
|
||||
```
|
||||
|
||||
### 使用建议
|
||||
|
||||
- **页面初始化**:使用 `refreshDevices()`
|
||||
- **用户手动刷新**:使用 `loadDevices(true)`
|
||||
- **定期更新**:使用 `loadDevices(false)`
|
||||
963
pages/sense/analysis.uvue
Normal file
963
pages/sense/analysis.uvue
Normal file
@@ -0,0 +1,963 @@
|
||||
<template>
|
||||
<scroll-view direction="vertical" class="analysis-container">
|
||||
<!-- 头部导航 -->
|
||||
<view class="header">
|
||||
<text class="title">数据分析</text>
|
||||
<view class="header-actions">
|
||||
<button class="export-btn" @click="exportReport">导出报告</button>
|
||||
<button class="refresh-btn" @click="refreshAnalysis">刷新</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 时间范围选择 -->
|
||||
<view class="time-range-card">
|
||||
<text class="card-title">分析时间范围</text>
|
||||
<view class="time-range-tabs">
|
||||
<button class="time-tab" :class="{ active: activeTimeRange == range }"
|
||||
v-for="(range, index) in timeRanges" :key="index" @click="selectTimeRange(range)">
|
||||
{{ getTimeRangeLabel(range) }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 总体健康评分 -->
|
||||
<view class="health-score-card">
|
||||
<text class="card-title">健康评分</text>
|
||||
<view class="score-display">
|
||||
<view class="score-circle">
|
||||
<text class="score-value">{{ overallScore }}</text>
|
||||
<text class="score-label">分</text>
|
||||
</view>
|
||||
<view class="score-details">
|
||||
<view class="score-item" v-for="(item, index) in scoreBreakdown" :key="index">
|
||||
<text class="score-category">{{ item.category }}</text>
|
||||
<view class="score-bar">
|
||||
<view class="score-progress" :style="{ width: item.percentage + '%' }"></view>
|
||||
</view>
|
||||
<text class="score-number">{{ item.score }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 指标趋势分析 -->
|
||||
<view class="trends-card">
|
||||
<text class="card-title">指标趋势</text>
|
||||
<view class="trend-tabs">
|
||||
<button class="trend-tab" :class="{ active: activeTrendType == type }"
|
||||
v-for="(type, index) in trendTypes" :key="index" @click="selectTrendType(type)">
|
||||
{{ getTrendTypeLabel(type) }}
|
||||
</button>
|
||||
</view>
|
||||
<ak-charts :option="trendChartOption" :canvas-id="'trend-analysis-chart'" class="trend-chart" />
|
||||
<view class="trend-summary">
|
||||
<text class="trend-text">{{ trendSummary }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 异常检测 -->
|
||||
<view class="anomaly-card" v-if="anomalies.length > 0">
|
||||
<text class="card-title">异常检测</text>
|
||||
<view class="anomaly-list">
|
||||
<view class="anomaly-item" v-for="(anomaly, index) in anomalies" :key="index">
|
||||
<view class="anomaly-header">
|
||||
<text class="anomaly-type">{{ anomaly.type }}</text>
|
||||
<text class="anomaly-severity" :class="getSeverityClass(anomaly.severity)">
|
||||
{{ anomaly.severity }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="anomaly-description">{{ anomaly.description }}</text>
|
||||
<text class="anomaly-time">{{ formatTime(anomaly.detected_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- AI分析报告 -->
|
||||
<view class="ai-report-card">
|
||||
<text class="card-title">AI分析报告</text>
|
||||
<view class="report-status" v-if="isAnalyzing">
|
||||
<text class="analyzing-text">AI正在分析您的数据...</text>
|
||||
<view class="analyzing-indicator"></view>
|
||||
</view>
|
||||
<view class="report-content" v-else-if="aiReport !== null">
|
||||
<view class="report-section">
|
||||
<text class="section-title">健康状况评估</text>
|
||||
<text class="section-content">{{ aiReport?.health_assessment ?? '' }}</text>
|
||||
</view>
|
||||
<view class="report-section">
|
||||
<text class="section-title">趋势分析</text>
|
||||
<text class="section-content">{{ aiReport?.trend_analysis ?? '' }}</text>
|
||||
</view>
|
||||
<view class="report-section">
|
||||
<text class="section-title">个性化建议</text>
|
||||
<view class="recommendations">
|
||||
<view class="recommendation-item" v-for="(rec, index) in (aiReport?.recommendations ?? [])"
|
||||
:key="index">
|
||||
<text class="rec-icon">💡</text>
|
||||
<text class="rec-text">{{ rec }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<button class="analyze-btn" v-else @click="startAIAnalysis">开始AI分析</button>
|
||||
</view>
|
||||
|
||||
<!-- 数据对比 -->
|
||||
<view class="comparison-card">
|
||||
<text class="card-title">数据对比</text>
|
||||
<view class="comparison-options">
|
||||
<view class="picker-view" @click="showComparisonPicker">
|
||||
<text>与{{ comparisonPeriods[comparisonIndex] }}对比</text>
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="comparison-results" v-if="comparisonData.length > 0">
|
||||
<view class="comparison-item" v-for="(item, index) in comparisonData" :key="index">
|
||||
<text class="comparison-metric">{{ item.metric_name }}</text>
|
||||
<view class="comparison-values">
|
||||
<text class="current-value">当前: {{ item.current_period }}</text>
|
||||
<text class="previous-value">之前: {{ item.previous_period }}</text>
|
||||
<text class="change-value" :class="getChangeClass(item.change_percentage)">
|
||||
{{ item.change_percentage > 0 ? '+' : '' }}{{ item.change_percentage }}%
|
||||
</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 } from 'vue'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import { SensorMeasurement, HealthScoreBreakdown, HealthAnomaly, AIAnalysisReport, TrendComparison } from './types.uts'
|
||||
import { setClipboardData, SetClipboardDataOption } from '@/uni_modules/lime-clipboard'
|
||||
|
||||
// 响应式数据
|
||||
const activeTimeRange = ref<string>('7d')
|
||||
const activeTrendType = ref<string>('heart_rate')
|
||||
const overallScore = ref<number>(82)
|
||||
const scoreBreakdown = ref<Array<HealthScoreBreakdown>>([])
|
||||
const trendChartOption = ref<UTSJSONObject>(new UTSJSONObject())
|
||||
const trendSummary = ref<string>('')
|
||||
const anomalies = ref<Array<HealthAnomaly>>([])
|
||||
const aiReport = ref<AIAnalysisReport | null>(null)
|
||||
const isAnalyzing = ref<boolean>(false)
|
||||
const comparisonIndex = ref<number>(0)
|
||||
const comparisonData = ref<Array<TrendComparison>>([])
|
||||
|
||||
// 选项数据
|
||||
const timeRanges = ['24h', '7d', '30d', '90d']
|
||||
const trendTypes = ['heart_rate', 'steps', 'spo2', 'bp', 'temp']
|
||||
const comparisonPeriods = ['上周', '上月', '上季度', '去年同期']
|
||||
|
||||
const userId = 'eed3824b-bba1-4309-8048-19d17367c084'
|
||||
|
||||
|
||||
async function loadHealthScore() {
|
||||
// 模拟健康评分数据
|
||||
const mockScoreBreakdown : Array<HealthScoreBreakdown> = [
|
||||
{
|
||||
category: '心率',
|
||||
score: 85,
|
||||
percentage: 85,
|
||||
trend: 'stable',
|
||||
color: '#4CAF50'
|
||||
},
|
||||
{
|
||||
category: '运动',
|
||||
score: 78,
|
||||
percentage: 78,
|
||||
trend: 'up',
|
||||
color: '#2196F3'
|
||||
},
|
||||
{
|
||||
category: '睡眠',
|
||||
score: 80,
|
||||
percentage: 80,
|
||||
trend: 'down',
|
||||
color: '#9C27B0'
|
||||
},
|
||||
{
|
||||
category: '血氧',
|
||||
score: 88,
|
||||
percentage: 88,
|
||||
trend: 'stable',
|
||||
color: '#FF9800'
|
||||
}
|
||||
]
|
||||
|
||||
scoreBreakdown.value = mockScoreBreakdown
|
||||
}
|
||||
|
||||
|
||||
|
||||
function exportReport() {
|
||||
// 构建报告数据
|
||||
const reportData = new UTSJSONObject()
|
||||
const currentAiReport = aiReport.value // 避免Smart cast问题
|
||||
reportData.set('user_id', userId)
|
||||
reportData.set('time_range', activeTimeRange.value)
|
||||
reportData.set('overall_score', overallScore.value)
|
||||
reportData.set('score_breakdown', scoreBreakdown.value)
|
||||
reportData.set('anomalies', anomalies.value)
|
||||
reportData.set('ai_report', currentAiReport)
|
||||
reportData.set('generated_at', new Date().toISOString())
|
||||
const jsonStr = JSON.stringify(reportData, null, 2)
|
||||
setClipboardData({
|
||||
data: jsonStr,
|
||||
success: (res : UniError) => {
|
||||
uni.showToast({
|
||||
title: '报告已复制到剪贴板',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
} as SetClipboardDataOption)
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
function getTimeRangeLabel(range : string) : string {
|
||||
const labels = new Map<string, string>()
|
||||
labels.set('24h', '24小时')
|
||||
labels.set('7d', '7天')
|
||||
labels.set('30d', '30天')
|
||||
labels.set('90d', '90天')
|
||||
|
||||
return labels.get(range) ?? range
|
||||
}
|
||||
|
||||
function getTrendTypeLabel(type : string) : string {
|
||||
const labels = new Map<string, string>()
|
||||
labels.set('heart_rate', '心率')
|
||||
labels.set('steps', '步数')
|
||||
labels.set('spo2', '血氧')
|
||||
labels.set('bp', '血压')
|
||||
labels.set('temp', '体温')
|
||||
|
||||
return labels.get(type) ?? type
|
||||
}
|
||||
|
||||
function getTrendColor(type : string) : string {
|
||||
const colors = new Map<string, string>()
|
||||
colors.set('heart_rate', '#FF6B6B')
|
||||
colors.set('steps', '#4ECDC4')
|
||||
colors.set('spo2', '#45B7D1')
|
||||
colors.set('bp', '#AB47BC')
|
||||
colors.set('temp', '#FFA726')
|
||||
|
||||
return colors.get(type) ?? '#2196F3'
|
||||
}
|
||||
|
||||
function getSeverityClass(severity : string) : string {
|
||||
if (severity == '高') return 'severity-high'
|
||||
if (severity == '中等') return 'severity-medium'
|
||||
return 'severity-low'
|
||||
}
|
||||
|
||||
function getChangeClass(change : number) : string {
|
||||
if (change > 0) return 'change-positive'
|
||||
if (change < 0) return 'change-negative'
|
||||
return 'change-neutral'
|
||||
}
|
||||
|
||||
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 < 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 days = Math.floor(diff / 86400000)
|
||||
return `${days}天前`
|
||||
}
|
||||
|
||||
}
|
||||
function generateTrendSummary(data : Array<SensorMeasurement>) {
|
||||
if (data.length == 0) {
|
||||
trendSummary.value = '暂无数据'
|
||||
return
|
||||
}
|
||||
|
||||
// 计算平均值和趋势
|
||||
let sum = 0
|
||||
let count = 0
|
||||
const values : Array<number> = []
|
||||
|
||||
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 (activeTrendType.value == 'heart_rate') {
|
||||
value = rawData.getNumber('bpm') ?? 0
|
||||
} else if (activeTrendType.value == 'steps') {
|
||||
value = rawData.getNumber('count') ?? 0
|
||||
} else if (activeTrendType.value == 'spo2') {
|
||||
value = rawData.getNumber('spo2') ?? 0
|
||||
} else if (activeTrendType.value == 'bp') {
|
||||
value = rawData.getNumber('systolic') ?? 0
|
||||
} else if (activeTrendType.value == 'temp') {
|
||||
value = rawData.getNumber('temp') ?? 0
|
||||
}
|
||||
|
||||
if (value > 0) {
|
||||
sum += value
|
||||
count++
|
||||
values.push(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
const average = sum / count
|
||||
const typeLabel = getTrendTypeLabel(activeTrendType.value)
|
||||
const timeLabel = getTimeRangeLabel(activeTimeRange.value)
|
||||
|
||||
// 计算趋势方向
|
||||
let trendDirection = '稳定'
|
||||
if (values.length > 1) {
|
||||
const firstHalf = values.slice(0, Math.floor(values.length / 2))
|
||||
const secondHalf = values.slice(Math.floor(values.length / 2))
|
||||
|
||||
const firstAvg = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
|
||||
const secondAvg = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length
|
||||
|
||||
const change = ((secondAvg - firstAvg) / firstAvg) * 100
|
||||
|
||||
if (change > 5) {
|
||||
trendDirection = '上升'
|
||||
} else if (change < -5) {
|
||||
trendDirection = '下降'
|
||||
}
|
||||
}
|
||||
|
||||
trendSummary.value = `${timeLabel}内${typeLabel}平均值为${average.toFixed(1)},整体趋势${trendDirection}`
|
||||
}
|
||||
}
|
||||
|
||||
function updateTrendChart(data : Array<SensorMeasurement>) {
|
||||
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 (activeTrendType.value == 'heart_rate') {
|
||||
value = rawData.getNumber('bpm') ?? 0
|
||||
} else if (activeTrendType.value == 'steps') {
|
||||
value = rawData.getNumber('count') ?? 0
|
||||
} else if (activeTrendType.value == 'spo2') {
|
||||
value = rawData.getNumber('spo2') ?? 0
|
||||
} else if (activeTrendType.value == 'bp') {
|
||||
value = rawData.getNumber('systolic') ?? 0
|
||||
} else if (activeTrendType.value == 'temp') {
|
||||
value = rawData.getNumber('temp') ?? 0
|
||||
}
|
||||
|
||||
chartData.push(value)
|
||||
|
||||
// 格式化时间标签
|
||||
const timeStr = item.measured_at
|
||||
const time = new Date(timeStr)
|
||||
let label = ''
|
||||
|
||||
if (activeTimeRange.value == '24h') {
|
||||
label = time.getHours().toString().padStart(2, '0') + ':' +
|
||||
time.getMinutes().toString().padStart(2, '0')
|
||||
} else {
|
||||
label = (time.getMonth() + 1).toString() + '/' + time.getDate().toString()
|
||||
}
|
||||
|
||||
chartLabels.push(label)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建图表配置
|
||||
const option = new UTSJSONObject()
|
||||
option.set('type', 'line')
|
||||
option.set('data', chartData)
|
||||
option.set('labels', chartLabels)
|
||||
option.set('color', getTrendColor(activeTrendType.value))
|
||||
option.set('smooth', true)
|
||||
|
||||
trendChartOption.value = option
|
||||
}
|
||||
|
||||
async function loadTrendData() {
|
||||
if (supa == null) return
|
||||
|
||||
try {
|
||||
// 根据时间范围计算开始时间
|
||||
const endTime = new Date()
|
||||
const startTime = new Date()
|
||||
|
||||
if (activeTimeRange.value == '24h') {
|
||||
startTime.setDate(startTime.getDate() - 1)
|
||||
} else if (activeTimeRange.value == '7d') {
|
||||
startTime.setDate(startTime.getDate() - 7)
|
||||
} else if (activeTimeRange.value == '30d') {
|
||||
startTime.setDate(startTime.getDate() - 30)
|
||||
} else if (activeTimeRange.value == '90d') {
|
||||
startTime.setDate(startTime.getDate() - 90)
|
||||
}
|
||||
|
||||
const result = await supa.from('ss_sensor_measurements')
|
||||
.eq('user_id', userId)
|
||||
.eq('measurement_type', activeTrendType.value)
|
||||
.gte('measured_at', startTime.toISOString())
|
||||
.lte('measured_at', endTime.toISOString())
|
||||
.order('measured_at', { ascending: true })
|
||||
.limit(100)
|
||||
.executeAs<Array<SensorMeasurement>>()
|
||||
if (result.data !== null) {
|
||||
if (Array.isArray(result.data)) {
|
||||
const dataArray = result.data as Array<any>
|
||||
const trendData : Array<SensorMeasurement> = []
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
trendData.push(dataArray[i] as SensorMeasurement)
|
||||
}
|
||||
updateTrendChart(trendData)
|
||||
generateTrendSummary(trendData)
|
||||
} else {
|
||||
updateTrendChart([])
|
||||
generateTrendSummary([])
|
||||
}
|
||||
} else {
|
||||
updateTrendChart([])
|
||||
generateTrendSummary([])
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('加载趋势数据失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadAnomalies() {
|
||||
// 模拟异常数据
|
||||
const mockAnomalies : Array<HealthAnomaly> = [
|
||||
{
|
||||
id: '1',
|
||||
type: '心率异常',
|
||||
severity: 'medium',
|
||||
description: '检测到心率持续偏高,建议关注',
|
||||
detected_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
resolved: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: '血压波动',
|
||||
severity: 'low',
|
||||
description: '血压值有轻微波动,持续观察',
|
||||
detected_at: new Date(Date.now() - 7200000).toISOString(),
|
||||
resolved: true
|
||||
}
|
||||
]
|
||||
|
||||
anomalies.value = mockAnomalies
|
||||
}
|
||||
|
||||
async function loadComparisonData() {
|
||||
// 模拟对比数据
|
||||
const mockComparison : Array<TrendComparison> = [
|
||||
{
|
||||
current_period: 72,
|
||||
previous_period: 75,
|
||||
change_percentage: -4.0,
|
||||
change_direction: 'down',
|
||||
metric_name: '平均心率',
|
||||
time_range: activeTimeRange.value
|
||||
},
|
||||
{
|
||||
current_period: 8500,
|
||||
previous_period: 7800,
|
||||
change_percentage: 9.0,
|
||||
change_direction: 'up',
|
||||
metric_name: '日均步数',
|
||||
time_range: activeTimeRange.value
|
||||
}]
|
||||
|
||||
comparisonData.value = mockComparison
|
||||
}
|
||||
|
||||
async function startAIAnalysis() {
|
||||
if (supa == null) return
|
||||
|
||||
isAnalyzing.value = true
|
||||
|
||||
try {
|
||||
// 调用AI分析RPC
|
||||
const analysisParams = new UTSJSONObject()
|
||||
analysisParams.set('user_id', userId)
|
||||
analysisParams.set('time_range', activeTimeRange.value)
|
||||
analysisParams.set('analysis_types', ['health_assessment', 'trend_analysis', 'recommendations'])
|
||||
const result = await supa.rpc('generate_health_analysis', analysisParams)
|
||||
if (result.data !== null && Array.isArray(result.data)) {
|
||||
const dataArray = result.data as Array<any>
|
||||
if (dataArray.length > 0) {
|
||||
const resultData = dataArray
|
||||
const reportData = resultData[0] as UTSJSONObject
|
||||
const mockReport : AIAnalysisReport = {
|
||||
health_assessment: reportData.getString('health_assessment') ?? '健康评估数据获取失败',
|
||||
trend_analysis: reportData.getString('trend_analysis') ?? '趋势分析数据获取失败',
|
||||
recommendations: reportData.getArray('recommendations') as string[] ?? [],
|
||||
risk_factors: reportData.getArray('risk_factors') as string[] ?? [],
|
||||
generated_at: reportData.getString('generated_at') ?? new Date().toISOString()
|
||||
|
||||
}
|
||||
aiReport.value = mockReport
|
||||
} else {
|
||||
// 模拟AI报告
|
||||
const mockReport : AIAnalysisReport = {
|
||||
health_assessment: '您的整体健康状况良好,各项指标基本正常。心率变异性较为稳定,运动量适中。',
|
||||
trend_analysis: '近期心率呈轻微下降趋势,说明心血管健康状况有所改善。步数波动较大,建议保持规律运动。',
|
||||
recommendations: [
|
||||
'建议每天保持8000步以上的运动量',
|
||||
'注意监测血压变化,定期检查',
|
||||
'保持良好的睡眠习惯,每天7-8小时',
|
||||
'饮食均衡,减少高盐高脂食物摄入'
|
||||
],
|
||||
risk_factors: ['血压偏高', '运动不规律'],
|
||||
generated_at: new Date().toISOString()
|
||||
|
||||
}
|
||||
aiReport.value = mockReport
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('AI分析失败:', e)
|
||||
uni.showToast({
|
||||
title: 'AI分析失败',
|
||||
icon: 'error'
|
||||
})
|
||||
} finally {
|
||||
isAnalyzing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function initializeData() {
|
||||
await loadHealthScore()
|
||||
await loadTrendData()
|
||||
await loadAnomalies()
|
||||
await loadComparisonData()
|
||||
}
|
||||
|
||||
function selectTimeRange(range : string) {
|
||||
activeTimeRange.value = range
|
||||
loadTrendData()
|
||||
}
|
||||
|
||||
function selectTrendType(type : string) {
|
||||
activeTrendType.value = type
|
||||
loadTrendData()
|
||||
}
|
||||
function showComparisonPicker() {
|
||||
uni.showActionSheet({
|
||||
itemList: comparisonPeriods,
|
||||
success: (res) => {
|
||||
if (res.tapIndex >= 0) {
|
||||
comparisonIndex.value = res.tapIndex
|
||||
loadComparisonData()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function refreshAnalysis() {
|
||||
initializeData()
|
||||
}
|
||||
onMounted(() => {
|
||||
initializeData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analysis-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;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.export-btn,
|
||||
.refresh-btn {
|
||||
padding: 16rpx 24rpx;
|
||||
margin-left: 16rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
background-color: #67C23A;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background-color: #409EFF;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.time-range-card,
|
||||
.health-score-card,
|
||||
.trends-card,
|
||||
.anomaly-card,
|
||||
.ai-report-card,
|
||||
.comparison-card {
|
||||
margin-bottom: 20rpx;
|
||||
padding: 24rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.time-range-tabs,
|
||||
.trend-tabs {
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.time-tab,
|
||||
.trend-tab {
|
||||
flex: 1;
|
||||
padding: 16rpx;
|
||||
margin: 0 8rpx;
|
||||
background-color: #f0f0f0;
|
||||
color: #666666;
|
||||
border-radius: 8rpx;
|
||||
font-size: 26rpx;
|
||||
border: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.time-tab.active,
|
||||
.trend-tab.active {
|
||||
background-color: #409EFF;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.score-display {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.score-circle {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 100rpx;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 40rpx;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-size: 64rpx;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
font-size: 24rpx;
|
||||
color: #ffffff;
|
||||
margin-top: -8rpx;
|
||||
}
|
||||
|
||||
.score-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.score-item {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.score-category {
|
||||
width: 100rpx;
|
||||
font-size: 26rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.score-bar {
|
||||
flex: 1;
|
||||
height: 16rpx;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 8rpx;
|
||||
margin: 0 16rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.score-progress {
|
||||
height: 100%;
|
||||
background-color: #409EFF;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.score-number {
|
||||
width: 60rpx;
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
height: 400rpx;
|
||||
margin: 20rpx 0;
|
||||
}
|
||||
|
||||
.trend-summary {
|
||||
padding: 20rpx;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.trend-text {
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.anomaly-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.anomaly-item {
|
||||
padding: 20rpx;
|
||||
margin-bottom: 16rpx;
|
||||
background-color: #fff5f5;
|
||||
border-radius: 8rpx;
|
||||
border-left: 8rpx solid #f56c6c;
|
||||
}
|
||||
|
||||
.anomaly-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.anomaly-type {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.anomaly-severity {
|
||||
padding: 6rpx 12rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.severity-high {
|
||||
background-color: #fde2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.severity-medium {
|
||||
background-color: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.severity-low {
|
||||
background-color: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.anomaly-description {
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.anomaly-time {
|
||||
font-size: 22rpx;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.report-status {
|
||||
align-items: center;
|
||||
padding: 40rpx 0;
|
||||
}
|
||||
|
||||
.analyzing-text {
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.analyzing-indicator {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border: 4rpx solid #f0f0f0;
|
||||
border-top: 4rpx solid #409EFF;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.report-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.report-section {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.recommendations {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.recommendation-item {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.rec-icon {
|
||||
font-size: 28rpx;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.rec-text {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.analyze-btn {
|
||||
padding: 24rpx 48rpx;
|
||||
background-color: #409EFF;
|
||||
color: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.comparison-options {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.picker-view {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
border: 2rpx solid #e0e0e0;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.picker-arrow {
|
||||
color: #999999;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.comparison-results {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.comparison-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.comparison-metric {
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.comparison-values {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.current-value,
|
||||
.previous-value {
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.change-value {
|
||||
font-size: 26rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.change-positive {
|
||||
color: #67C23A;
|
||||
}
|
||||
|
||||
.change-negative {
|
||||
color: #F56C6C;
|
||||
}
|
||||
|
||||
.change-neutral {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
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>
|
||||
253
pages/sense/deviceStoreExample.uts
Normal file
253
pages/sense/deviceStoreExample.uts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 设备 Store 使用示例
|
||||
* 展示如何在不同页面和组件中使用设备状态管理
|
||||
*/
|
||||
|
||||
import { getDeviceStore } from '@/utils/store.uts'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
export class DeviceStoreUsageExample {
|
||||
|
||||
/**
|
||||
* 示例1:在页面组件中使用设备列表
|
||||
*/
|
||||
static setupDeviceList() {
|
||||
const deviceStore = getDeviceStore()
|
||||
|
||||
// 获取响应式设备列表
|
||||
const devices = computed(() => deviceStore.getDevices())
|
||||
const isLoading = computed(() => deviceStore.isLoading())
|
||||
const currentDevice = computed(() => deviceStore.getCurrentDevice())
|
||||
|
||||
// 页面挂载时加载设备
|
||||
onMounted(async () => {
|
||||
await deviceStore.loadDevices() // 自动缓存,5分钟内不重复请求
|
||||
})
|
||||
|
||||
// 强制刷新设备列表
|
||||
const refreshDevices = async () => {
|
||||
await deviceStore.loadDevices(true) // 强制刷新
|
||||
}
|
||||
|
||||
// 选择设备
|
||||
const selectDevice = (device: any) => {
|
||||
deviceStore.setCurrentDevice(device)
|
||||
}
|
||||
|
||||
return {
|
||||
devices,
|
||||
isLoading,
|
||||
currentDevice,
|
||||
refreshDevices,
|
||||
selectDevice
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例2:设备管理操作
|
||||
*/
|
||||
static setupDeviceManagement() {
|
||||
const deviceStore = getDeviceStore()
|
||||
|
||||
// 绑定新设备
|
||||
const bindNewDevice = async (deviceData: UTSJSONObject) => {
|
||||
const success = await deviceStore.bindDevice(deviceData)
|
||||
if (success) {
|
||||
console.log('设备绑定成功')
|
||||
// 设备已自动添加到store中,UI会自动更新
|
||||
} else {
|
||||
console.log('设备绑定失败')
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
// 解绑设备
|
||||
const unbindDevice = async (deviceId: string) => {
|
||||
const success = await deviceStore.unbindDevice(deviceId)
|
||||
if (success) {
|
||||
console.log('设备解绑成功')
|
||||
// 设备已自动从store中移除,UI会自动更新
|
||||
} else {
|
||||
console.log('设备解绑失败')
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
// 更新设备配置
|
||||
const updateDeviceConfig = async (deviceId: string, configData: UTSJSONObject) => {
|
||||
const success = await deviceStore.updateDevice(deviceId, configData)
|
||||
if (success) {
|
||||
console.log('设备配置更新成功')
|
||||
// 设备信息已自动更新,UI会自动刷新
|
||||
} else {
|
||||
console.log('设备配置更新失败')
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
return {
|
||||
bindNewDevice,
|
||||
unbindDevice,
|
||||
updateDeviceConfig
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例3:设备状态查询
|
||||
*/
|
||||
static setupDeviceQuery() {
|
||||
const deviceStore = getDeviceStore()
|
||||
|
||||
// 获取在线设备
|
||||
const getOnlineDevices = () => {
|
||||
return deviceStore.getOnlineDevices()
|
||||
}
|
||||
|
||||
// 根据ID获取设备
|
||||
const getDeviceById = (deviceId: string) => {
|
||||
return deviceStore.getDeviceById(deviceId)
|
||||
}
|
||||
|
||||
// 获取设备数量
|
||||
const getDeviceCount = () => {
|
||||
return deviceStore.getDevices().length
|
||||
}
|
||||
|
||||
// 检查是否有设备
|
||||
const hasDevices = computed(() => {
|
||||
return deviceStore.getDevices().length > 0
|
||||
})
|
||||
|
||||
// 获取数据最后更新时间
|
||||
const getLastUpdated = () => {
|
||||
const timestamp = deviceStore.getLastUpdated()
|
||||
if (timestamp) {
|
||||
return new Date(timestamp).toLocaleString()
|
||||
}
|
||||
return '未更新'
|
||||
}
|
||||
|
||||
return {
|
||||
getOnlineDevices,
|
||||
getDeviceById,
|
||||
getDeviceCount,
|
||||
hasDevices,
|
||||
getLastUpdated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例4:在多个页面间共享设备状态
|
||||
*/
|
||||
static setupDeviceSync() {
|
||||
const deviceStore = getDeviceStore()
|
||||
|
||||
// 页面A:设备列表页面
|
||||
const setupDeviceListPage = () => {
|
||||
const devices = computed(() => deviceStore.getDevices())
|
||||
const currentDevice = computed(() => deviceStore.getCurrentDevice())
|
||||
|
||||
// 选择设备并导航到详情页
|
||||
const viewDeviceDetail = (device: any) => {
|
||||
deviceStore.setCurrentDevice(device)
|
||||
uni.navigateTo({
|
||||
url: `/pages/sense/detail?device_id=${device.id}`
|
||||
})
|
||||
}
|
||||
|
||||
return { devices, currentDevice, viewDeviceDetail }
|
||||
}
|
||||
|
||||
// 页面B:设备详情页面
|
||||
const setupDeviceDetailPage = () => {
|
||||
const currentDevice = computed(() => deviceStore.getCurrentDevice())
|
||||
|
||||
// 如果没有当前设备,尝试从URL参数获取
|
||||
onMounted(() => {
|
||||
if (!currentDevice.value) {
|
||||
// 从URL获取device_id并设置当前设备
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 0) {
|
||||
const currentPage = pages[pages.length - 1]
|
||||
const deviceId = currentPage.$scope.options.device_id
|
||||
if (deviceId) {
|
||||
const device = deviceStore.getDeviceById(deviceId)
|
||||
if (device) {
|
||||
deviceStore.setCurrentDevice(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { currentDevice }
|
||||
}
|
||||
|
||||
return {
|
||||
setupDeviceListPage,
|
||||
setupDeviceDetailPage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在Vue组件中的完整使用示例
|
||||
*/
|
||||
export function createDevicePageSetup() {
|
||||
return {
|
||||
name: 'DevicePage',
|
||||
setup() {
|
||||
const deviceStore = getDeviceStore()
|
||||
|
||||
// 响应式状态
|
||||
const devices = computed(() => deviceStore.getDevices())
|
||||
const isLoading = computed(() => deviceStore.isLoading())
|
||||
const currentDevice = computed(() => deviceStore.getCurrentDevice())
|
||||
|
||||
// 页面挂载
|
||||
onMounted(async () => {
|
||||
// 加载设备列表(自动缓存)
|
||||
await deviceStore.loadDevices()
|
||||
})
|
||||
|
||||
// 操作方法
|
||||
const refreshDevices = async () => {
|
||||
await deviceStore.loadDevices(true)
|
||||
}
|
||||
|
||||
const selectDevice = (device: any) => {
|
||||
deviceStore.setCurrentDevice(device)
|
||||
}
|
||||
|
||||
const bindDevice = async (deviceData: UTSJSONObject) => {
|
||||
return await deviceStore.bindDevice(deviceData)
|
||||
}
|
||||
|
||||
const unbindDevice = async (deviceId: string) => {
|
||||
return await deviceStore.unbindDevice(deviceId)
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const onlineDeviceCount = computed(() => {
|
||||
return deviceStore.getOnlineDevices().length
|
||||
})
|
||||
|
||||
const hasDevices = computed(() => {
|
||||
return devices.value.length > 0
|
||||
})
|
||||
|
||||
// 返回给模板使用
|
||||
return {
|
||||
devices,
|
||||
isLoading,
|
||||
currentDevice,
|
||||
onlineDeviceCount,
|
||||
hasDevices,
|
||||
refreshDevices,
|
||||
selectDevice,
|
||||
bindDevice,
|
||||
unbindDevice
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
832
pages/sense/devices.uvue
Normal file
832
pages/sense/devices.uvue
Normal file
@@ -0,0 +1,832 @@
|
||||
<template>
|
||||
<view class="device-container">
|
||||
<!-- 头部导航 -->
|
||||
<view class="header">
|
||||
<text class="title">设备管理</text>
|
||||
<view class="header-actions">
|
||||
<button class="scan-btn" @click="scanDevices">扫描设备</button>
|
||||
<button class="add-btn" @click="addDevice">添加设备</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 扫描状态 -->
|
||||
<view class="scan-status" v-if="isScanning">
|
||||
<view class="scan-indicator">
|
||||
<text class="scan-text">正在扫描设备...</text>
|
||||
<view class="scan-animation"></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 已绑定设备列表 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">已绑定设备 ({{ boundDevices.length }})</text>
|
||||
<button class="refresh-btn" @click="forceRefreshDevices" :disabled="isLoading">
|
||||
<text class="refresh-text">{{ isLoading ? '加载中...' : '刷新' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<view class="loading-status" v-if="isLoading">
|
||||
<text class="loading-text">正在加载设备列表...</text>
|
||||
</view>
|
||||
|
||||
<view class="device-list" v-else>
|
||||
<view class="device-item" v-for="(device, index) in boundDevices" :key="index"
|
||||
@click="selectDevice(device)">
|
||||
<view class="device-info">
|
||||
<view class="device-header">
|
||||
<text class="device-name">{{ device.device_name ?? '未知设备' }}</text>
|
||||
<view class="device-status">
|
||||
<text class="status-text">{{ getStatusText(device.status ?? 'offline') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="device-mac">MAC: {{ device.device_mac ?? '--' }}</text>
|
||||
<text class="device-type">类型: {{ getDeviceTypeLabel(device.device_type ?? '') }}</text>
|
||||
<text class="bind-time">绑定时间: {{ formatTime(device.bind_time ?? '') }}</text>
|
||||
</view>
|
||||
<view class="device-actions">
|
||||
<button class="config-btn" @click.stop="configDevice(device)">配置</button>
|
||||
<button class="unbind-btn" @click.stop="unbindDeviceById(device)">解绑</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 发现的设备列表 -->
|
||||
<view class="section" v-if="discoveredDevices.length > 0">
|
||||
<text class="section-title">发现的设备 ({{ discoveredDevices.length }})</text>
|
||||
<view class="device-list">
|
||||
<view class="discovered-item" v-for="(device, index) in discoveredDevices" :key="index">
|
||||
<view class="device-info">
|
||||
<text class="device-name">{{ device.name ?? '未知设备' }}</text>
|
||||
<text class="device-mac">MAC: {{ device.mac ?? '--' }}</text>
|
||||
<text class="device-rssi">信号强度: {{ device.rssi ?? 0 }} dBm</text>
|
||||
</view>
|
||||
<button class="bind-btn" @click="bindDevice(device)">绑定</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 设备配置弹窗 -->
|
||||
<view class="config-modal" v-if="showConfig" @click="closeConfig">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">设备配置</text>
|
||||
<button class="close-btn" @click="closeConfig">×</button>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<view class="config-form" v-if="currentDevice !== null">
|
||||
<view class="form-item">
|
||||
<text class="form-label">设备名称</text>
|
||||
<input class="form-input" :value="configForm.device_name" @input="onDeviceNameInput"
|
||||
placeholder="请输入设备名称" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">采样频率</text>
|
||||
<view class="picker-view" @click="showSampleRatePicker">
|
||||
<text>{{ sampleRates[configForm.sample_rate_index] }}</text>
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">数据上传间隔</text>
|
||||
<view class="picker-view" @click="showUploadIntervalPicker">
|
||||
<text>{{ uploadIntervals[configForm.upload_interval_index] }}</text>
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<switch :checked="configForm.auto_sync" @change="onAutoSyncChange" />
|
||||
<text class="switch-label">自动同步</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-actions">
|
||||
<button class="cancel-btn" @click="closeConfig">取消</button>
|
||||
<button class="save-btn" @click="saveConfig">保存</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { state, loadDevices, loadDevicesWithDefault, bindNewDevice, unbindDevice, updateDeviceConfig } from '@/utils/store.uts'
|
||||
import { DeviceInfo } from './types.uts'
|
||||
|
||||
// 响应式数据
|
||||
const discoveredDevices = ref<Array<UTSJSONObject>>([])
|
||||
const isScanning = ref<boolean>(false)
|
||||
const showConfig = ref<boolean>(false)
|
||||
const currentDevice = ref<DeviceInfo | null>(null)
|
||||
|
||||
// 从 state 直接获取设备列表 - 使用计算属性保持响应性
|
||||
const boundDevices = computed<Array<DeviceInfo>>(() => state.deviceState.devices)
|
||||
const isLoading = computed<boolean>(() => state.deviceState.isLoading) // 配置表单类型定义
|
||||
type ConfigForm = {
|
||||
device_name : string
|
||||
sample_rate_index : number
|
||||
upload_interval_index : number
|
||||
auto_sync : boolean
|
||||
}
|
||||
|
||||
// 配置表单
|
||||
const configForm = ref<ConfigForm>({
|
||||
device_name: '',
|
||||
sample_rate_index: 0,
|
||||
upload_interval_index: 0,
|
||||
auto_sync: true
|
||||
})
|
||||
// 选项数据
|
||||
const sampleRates = ['1Hz', '5Hz', '10Hz', '25Hz', '50Hz', '100Hz']
|
||||
const uploadIntervals = ['实时', '1分钟', '5分钟', '15分钟', '30分钟', '1小时']
|
||||
|
||||
// 异步解绑设备函数 - 需要在使用前定义
|
||||
async function performUnbind(deviceId : string) {
|
||||
try {
|
||||
const success = await unbindDevice(deviceId)
|
||||
|
||||
if (success) {
|
||||
uni.showToast({
|
||||
title: '设备解绑成功',
|
||||
icon: 'success'
|
||||
})
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '设备解绑失败',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('解绑设备失败:', e)
|
||||
uni.showToast({
|
||||
title: '解绑失败',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function forceRefreshDevices() {
|
||||
try {
|
||||
const success = await loadDevices(true)
|
||||
if (!success) {
|
||||
console.log('强制刷新设备列表失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('强制刷新设备失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function scanDevices() {
|
||||
isScanning.value = true
|
||||
discoveredDevices.value = []
|
||||
|
||||
try {
|
||||
// 模拟蓝牙扫描过程
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
// 模拟发现的设备
|
||||
const mockDevices : Array<UTSJSONObject> = []
|
||||
|
||||
const device1 = new UTSJSONObject()
|
||||
device1.set('name', 'Smart Watch Pro')
|
||||
device1.set('mac', 'AA:BB:CC:DD:EE:01')
|
||||
device1.set('rssi', -45)
|
||||
device1.set('type', 'smartwatch')
|
||||
mockDevices.push(device1)
|
||||
|
||||
const device2 = new UTSJSONObject()
|
||||
device2.set('name', 'Fitness Band X')
|
||||
device2.set('mac', 'AA:BB:CC:DD:EE:02')
|
||||
device2.set('rssi', -62)
|
||||
device2.set('type', 'fitness_band')
|
||||
mockDevices.push(device2)
|
||||
|
||||
discoveredDevices.value = mockDevices
|
||||
resolve()
|
||||
}, 3000)
|
||||
})
|
||||
} catch (e) {
|
||||
console.log('扫描设备失败:', e)
|
||||
} finally {
|
||||
isScanning.value = false
|
||||
}
|
||||
} async function bindDevice(device : UTSJSONObject) {
|
||||
try {
|
||||
const deviceData = new UTSJSONObject()
|
||||
deviceData.set('device_type', device.getString('type') ?? 'unknown')
|
||||
deviceData.set('device_name', device.getString('name') ?? '未知设备')
|
||||
deviceData.set('device_mac', device.getString('mac') ?? '')
|
||||
|
||||
const extra = new UTSJSONObject()
|
||||
extra.set('rssi', device.getNumber('rssi') ?? 0)
|
||||
extra.set('sample_rate', '10Hz')
|
||||
extra.set('upload_interval', '5分钟')
|
||||
extra.set('auto_sync', true)
|
||||
deviceData.set('extra', extra)
|
||||
|
||||
const success = await bindNewDevice(deviceData)
|
||||
|
||||
if (success) {
|
||||
uni.showToast({
|
||||
title: '设备绑定成功',
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
// 从发现列表中移除
|
||||
const mac = device.getString('mac') ?? ''
|
||||
discoveredDevices.value = discoveredDevices.value.filter(d =>
|
||||
d.getString('mac') !== mac
|
||||
)
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '设备绑定失败',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('绑定设备失败:', e)
|
||||
uni.showToast({
|
||||
title: '绑定失败',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function unbindDeviceById(device : DeviceInfo) {
|
||||
const deviceId = device.id
|
||||
const deviceName = device.device_name
|
||||
|
||||
uni.showModal({
|
||||
title: '确认解绑',
|
||||
content: `确定要解绑设备"${deviceName}"吗?`,
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// 异步操作需要单独处理
|
||||
performUnbind(deviceId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
function configDevice(device : DeviceInfo) {
|
||||
currentDevice.value = device
|
||||
|
||||
// 初始化配置表单 - 使用 configForm.value 访问
|
||||
const form = configForm.value
|
||||
form.device_name = device.device_name ?? ''
|
||||
|
||||
const extra = device.extra
|
||||
if (extra !== null) {
|
||||
const sampleRate = extra.getString('sample_rate') ?? '10Hz'
|
||||
form.sample_rate_index = sampleRates.indexOf(sampleRate)
|
||||
if (form.sample_rate_index === -1) {
|
||||
form.sample_rate_index = 2 // 默认10Hz
|
||||
}
|
||||
|
||||
const uploadInterval = extra.getString('upload_interval') ?? '5分钟'
|
||||
form.upload_interval_index = uploadIntervals.indexOf(uploadInterval)
|
||||
if (form.upload_interval_index === -1) {
|
||||
form.upload_interval_index = 2 // 默认5分钟
|
||||
}
|
||||
form.auto_sync = extra.getBoolean('auto_sync') ?? true
|
||||
}
|
||||
|
||||
showConfig.value = true
|
||||
} async function saveConfig() {
|
||||
const device = currentDevice.value
|
||||
if (device === null) return
|
||||
|
||||
try {
|
||||
const deviceId = device.id
|
||||
const form = configForm.value
|
||||
|
||||
const extra = new UTSJSONObject()
|
||||
extra.set('sample_rate', sampleRates[form.sample_rate_index])
|
||||
extra.set('upload_interval', uploadIntervals[form.upload_interval_index])
|
||||
extra.set('auto_sync', form.auto_sync)
|
||||
|
||||
const updateData = new UTSJSONObject()
|
||||
updateData.set('device_name', form.device_name)
|
||||
updateData.set('extra', extra)
|
||||
|
||||
const success = await updateDeviceConfig(deviceId, updateData)
|
||||
|
||||
if (success) {
|
||||
uni.showToast({
|
||||
title: '配置保存成功',
|
||||
icon: 'success'
|
||||
})
|
||||
showConfig.value = false
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '配置保存失败',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('保存配置失败:', e)
|
||||
uni.showToast({
|
||||
title: '保存失败',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
function selectDevice(device : DeviceInfo) {
|
||||
const deviceId = device.id
|
||||
uni.navigateTo({
|
||||
url: `/pages/sense/index?device_id=${deviceId}`
|
||||
})
|
||||
}
|
||||
|
||||
function addDevice() {
|
||||
scanDevices()
|
||||
} function closeConfig() {
|
||||
showConfig.value = false
|
||||
currentDevice.value = null
|
||||
}
|
||||
// Event handlers for form controls
|
||||
function onDeviceNameInput(event : any) {
|
||||
const form = configForm.value
|
||||
try {
|
||||
const eventObj = event as UTSJSONObject
|
||||
const detail = eventObj.get('detail') as UTSJSONObject
|
||||
if (detail != null) {
|
||||
const value = detail.get('value')
|
||||
if (value != null) {
|
||||
form.device_name = value.toString()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error in onDeviceNameInput:', e)
|
||||
}
|
||||
}
|
||||
function showSampleRatePicker() {
|
||||
uni.showActionSheet({
|
||||
itemList: sampleRates,
|
||||
success: (res) => {
|
||||
if (res.tapIndex >= 0) {
|
||||
configForm.value.sample_rate_index = res.tapIndex
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function showUploadIntervalPicker() {
|
||||
uni.showActionSheet({
|
||||
itemList: uploadIntervals,
|
||||
success: (res) => {
|
||||
if (res.tapIndex >= 0) {
|
||||
configForm.value.upload_interval_index = res.tapIndex
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onSampleRateChange(event : any) {
|
||||
const form = configForm.value
|
||||
try {
|
||||
const eventObj = event as UTSJSONObject
|
||||
const detail = eventObj.get('detail') as UTSJSONObject
|
||||
if (detail != null) {
|
||||
const value = detail.get('value')
|
||||
if (value != null) {
|
||||
form.sample_rate_index = parseInt(value.toString())
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error in onSampleRateChange:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function onUploadIntervalChange(event : any) {
|
||||
const form = configForm.value
|
||||
try {
|
||||
const eventObj = event as UTSJSONObject
|
||||
const detail = eventObj.get('detail') as UTSJSONObject
|
||||
if (detail != null) {
|
||||
const value = detail.get('value')
|
||||
if (value != null) {
|
||||
form.upload_interval_index = parseInt(value.toString())
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error in onUploadIntervalChange:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function onAutoSyncChange(event : any) {
|
||||
const form = configForm.value
|
||||
try {
|
||||
const eventObj = event as UTSJSONObject
|
||||
const detail = eventObj.get('detail') as UTSJSONObject
|
||||
if (detail != null) {
|
||||
const value = detail.get('value')
|
||||
if (value != null) {
|
||||
form.auto_sync = value as boolean
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error in onAutoSyncChange:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
function getStatusText(status : string) : string {
|
||||
const statusMap = new Map<string, string>()
|
||||
statusMap.set('active', '在线')
|
||||
statusMap.set('inactive', '离线')
|
||||
statusMap.set('offline', '离线')
|
||||
|
||||
return statusMap.get(status) ?? '未知'
|
||||
}
|
||||
|
||||
function getStatusClass(status : string) : string {
|
||||
if (status === 'active') {
|
||||
return 'status-online'
|
||||
}
|
||||
return 'status-offline'
|
||||
}
|
||||
|
||||
function getDeviceTypeLabel(type : string) : string {
|
||||
const typeMap = new Map<string, string>()
|
||||
typeMap.set('smartwatch', '智能手表')
|
||||
typeMap.set('fitness_band', '健身手环')
|
||||
typeMap.set('heart_monitor', '心率监测器')
|
||||
typeMap.set('blood_pressure', '血压计')
|
||||
typeMap.set('thermometer', '体温计')
|
||||
|
||||
return typeMap.get(type) ?? '未知设备'
|
||||
}
|
||||
function formatTime(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')
|
||||
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
async function loadBoundDevices() {
|
||||
try {
|
||||
const success = await loadDevicesWithDefault()
|
||||
if (!success) {
|
||||
console.log('加载设备列表失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('加载绑定设备失败:', e)
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
loadBoundDevices()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.device-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;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.scan-btn,
|
||||
.add-btn {
|
||||
padding: 16rpx 24rpx;
|
||||
margin-left: 16rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.scan-btn {
|
||||
background-color: #67C23A;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background-color: #409EFF;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.scan-status {
|
||||
margin-bottom: 20rpx;
|
||||
padding: 32rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scan-indicator {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scan-text {
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.scan-animation {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border: 4rpx solid #f0f0f0;
|
||||
border-top: 4rpx solid #409EFF;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 16rpx;
|
||||
padding-left: 8rpx;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 12rpx 24rpx;
|
||||
background-color: #409EFF;
|
||||
color: #ffffff;
|
||||
border-radius: 6rpx;
|
||||
font-size: 24rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.refresh-btn[disabled] {
|
||||
background-color: #cccccc;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.refresh-text {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.device-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.device-item,
|
||||
.discovered-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 24rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.device-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.device-status {
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
background-color: #E8F5E8;
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
background-color: #FFF1F0;
|
||||
}
|
||||
|
||||
.status-online .status-text {
|
||||
color: #52C41A;
|
||||
}
|
||||
|
||||
.status-offline .status-text {
|
||||
color: #FF4D4F;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.device-mac,
|
||||
.device-type,
|
||||
.bind-time,
|
||||
.device-rssi {
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.device-actions {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.config-btn,
|
||||
.unbind-btn,
|
||||
.bind-btn {
|
||||
padding: 12rpx 20rpx;
|
||||
margin-left: 12rpx;
|
||||
border-radius: 6rpx;
|
||||
font-size: 24rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.config-btn {
|
||||
background-color: #E6A23C;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.unbind-btn {
|
||||
background-color: #F56C6C;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.bind-btn {
|
||||
background-color: #67C23A;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.config-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: 85%;
|
||||
max-height: 80%;
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 32rpx;
|
||||
border-bottom: 2rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.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 {
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
width: 200rpx;
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
padding: 20rpx;
|
||||
border: 2rpx solid #e0e0e0;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.picker-view {
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
border: 2rpx solid #e0e0e0;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.picker-arrow {
|
||||
color: #999999;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
margin-left: 16rpx;
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.save-btn {
|
||||
padding: 20rpx 40rpx;
|
||||
margin-left: 16rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background-color: #f0f0f0;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background-color: #409EFF;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.loading-status {
|
||||
padding: 32rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
2454
pages/sense/healthble.uvue
Normal file
2454
pages/sense/healthble.uvue
Normal file
File diff suppressed because it is too large
Load Diff
951
pages/sense/index.uvue
Normal file
951
pages/sense/index.uvue
Normal 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>
|
||||
92
pages/sense/insertExample.uts
Normal file
92
pages/sense/insertExample.uts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 演示新的统一插入功能的使用示例
|
||||
*/
|
||||
|
||||
import { SenseDataService } from './senseDataService.uts'
|
||||
|
||||
export class InsertExample {
|
||||
|
||||
/**
|
||||
* 单个插入示例
|
||||
*/
|
||||
static async insertSingleMeasurement() {
|
||||
const data = new UTSJSONObject()
|
||||
data.set('device_id', 'device_001')
|
||||
data.set('user_id', 'user_001')
|
||||
data.set('measurement_type', 'heart_rate')
|
||||
data.set('measured_at', new Date().toISOString())
|
||||
data.set('unit', 'bpm')
|
||||
const rawData = new UTSJSONObject()
|
||||
rawData.set('value', 75)
|
||||
data.set('raw_data', rawData)
|
||||
|
||||
// 使用统一的 createMeasurement 方法 - 自动检测为单个插入
|
||||
const result = await SenseDataService.createMeasurement(data)
|
||||
|
||||
if (result.success) {
|
||||
console.log('单个插入成功:', result.data)
|
||||
} else {
|
||||
console.log('单个插入失败:', result.msg)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量插入示例
|
||||
*/
|
||||
static async insertBatchMeasurements() {
|
||||
const dataList: Array<UTSJSONObject> = []
|
||||
|
||||
// 创建多个测量数据
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const data = new UTSJSONObject()
|
||||
data.set('device_id', 'device_001')
|
||||
data.set('user_id', 'user_001')
|
||||
data.set('measurement_type', 'steps')
|
||||
data.set('measured_at', new Date(Date.now() - i * 60000).toISOString()) // 每分钟一个数据点
|
||||
data.set('unit', 'steps')
|
||||
const rawData = new UTSJSONObject()
|
||||
rawData.set('value', 1000 + i * 100)
|
||||
data.set('raw_data', rawData)
|
||||
dataList.push(data)
|
||||
}
|
||||
|
||||
// 使用统一的 createMeasurement 方法 - 自动检测为批量插入
|
||||
const result = await SenseDataService.createMeasurement(dataList)
|
||||
|
||||
if (result.success) {
|
||||
console.log('批量插入成功,插入条数:', Array.isArray(result.data) ? result.data.length : 1)
|
||||
} else {
|
||||
console.log('批量插入失败:', result.msg)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
/**
|
||||
* 使用 bindDevice 方法插入设备数据
|
||||
*/
|
||||
static async insertDeviceData() {
|
||||
const deviceData = new UTSJSONObject()
|
||||
deviceData.set('user_id', 'user_001')
|
||||
deviceData.set('device_type', 'smartwatch')
|
||||
deviceData.set('device_name', 'Apple Watch')
|
||||
deviceData.set('device_mac', 'AA:BB:CC:DD:EE:FF')
|
||||
deviceData.set('status', 'online')
|
||||
const extra = new UTSJSONObject()
|
||||
extra.set('model', 'Series 9')
|
||||
extra.set('version', '10.1')
|
||||
deviceData.set('extra', extra)
|
||||
|
||||
// 使用 bindDevice 方法
|
||||
const result = await SenseDataService.bindDevice(deviceData)
|
||||
|
||||
if (result.success) {
|
||||
console.log('设备数据插入成功:', result.data)
|
||||
} else {
|
||||
console.log('设备数据插入失败:', result.msg)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
806
pages/sense/nav.uvue
Normal file
806
pages/sense/nav.uvue
Normal file
@@ -0,0 +1,806 @@
|
||||
<template>
|
||||
<view class="nav-container">
|
||||
<!-- 头部标题 -->
|
||||
<view class="header">
|
||||
<text class="main-title">智能传感器中心</text>
|
||||
<text class="sub-title">运动与健康数据监控平台</text>
|
||||
</view>
|
||||
|
||||
<!-- 快速状态卡片 -->
|
||||
<view class="status-cards">
|
||||
<view class="status-card">
|
||||
<text class="card-icon">📱</text>
|
||||
<text class="card-title">设备状态</text>
|
||||
<text class="card-value">{{ deviceStatus.active }}/{{ deviceStatus.total }}</text>
|
||||
<text class="card-unit">台在线</text>
|
||||
</view>
|
||||
<view class="status-card">
|
||||
<text class="card-icon">📊</text>
|
||||
<text class="card-title">今日数据</text>
|
||||
<text class="card-value">{{ todayData.count }}</text>
|
||||
<text class="card-unit">条记录</text>
|
||||
</view>
|
||||
<view class="status-card">
|
||||
<text class="card-icon">⚡</text>
|
||||
<text class="card-title">实时监控</text>
|
||||
<text class="card-value">{{ realtimeStatus ? '开启' : '关闭' }}</text>
|
||||
<text class="card-unit">{{ realtimeStatus ? '🟢' : '🔴' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 主要功能导航 -->
|
||||
<view class="main-nav">
|
||||
<text class="nav-section-title">主要功能</text>
|
||||
<view class="nav-grid">
|
||||
<view class="nav-item large" @click="navigateTo('/pages/sense/index')">
|
||||
<view class="nav-icon-container">
|
||||
<text class="nav-icon">📈</text>
|
||||
</view>
|
||||
<text class="nav-title">实时监控</text>
|
||||
<text class="nav-desc">查看传感器实时数据与趋势</text>
|
||||
</view>
|
||||
<view class="nav-item large" @click="navigateTo('/pages/sense/analysis')">
|
||||
<view class="nav-icon-container">
|
||||
<text class="nav-icon">🧠</text>
|
||||
</view>
|
||||
<text class="nav-title">数据分析</text>
|
||||
<text class="nav-desc">AI智能分析与健康评估</text>
|
||||
</view>
|
||||
<view class="nav-item large" @click="navigateTo('/pages/sense/healthble')">
|
||||
<view class="nav-icon-container">
|
||||
<text class="nav-icon">🩺</text>
|
||||
</view>
|
||||
<text class="nav-title">蓝牙仪表</text>
|
||||
<text class="nav-desc">连接手环并处理报警/录音</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 设备管理 -->
|
||||
<view class="device-section">
|
||||
<text class="nav-section-title">设备管理</text>
|
||||
<view class="nav-grid">
|
||||
<view class="nav-item" @click="navigateTo('/pages/sense/devices')">
|
||||
<text class="nav-icon">📱</text>
|
||||
<text class="nav-title">设备管理</text>
|
||||
</view>
|
||||
<view class="nav-item" @click="navigateTo('/pages/sense/settings')">
|
||||
<text class="nav-icon">⚙️</text>
|
||||
<text class="nav-title">传感器设置</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据管理 -->
|
||||
<view class="data-section">
|
||||
<text class="nav-section-title">数据管理</text>
|
||||
<view class="data-options">
|
||||
<button class="data-btn" @click="exportAllData">
|
||||
<text class="btn-icon">📤</text>
|
||||
<text class="btn-text">导出数据</text>
|
||||
</button>
|
||||
<button class="data-btn" @click="syncData">
|
||||
<text class="btn-icon">🔄</text>
|
||||
<text class="btn-text">同步数据</text>
|
||||
</button>
|
||||
<button class="data-btn" @click="showDataStats">
|
||||
<text class="btn-icon">📊</text>
|
||||
<text class="btn-text">数据统计</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最近数据预览 -->
|
||||
<view class="recent-section">
|
||||
<view class="recent-header">
|
||||
<text class="nav-section-title">最近测量</text>
|
||||
<button class="more-btn" @click="navigateTo('/pages/sense/index')">查看更多</button>
|
||||
</view>
|
||||
<scroll-view class="recent-list" scroll-x>
|
||||
<view class="recent-item" v-for="(item, index) in recentData" :key="index" @click="viewDetail(item)">
|
||||
<text class="recent-type">{{ getTypeLabel(item.getString('measurement_type') ?? '') }}</text>
|
||||
<text class="recent-value">{{ formatValue(item) }}</text>
|
||||
<text class="recent-time">{{ formatTime(item.getString('measured_at') ?? '') }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 健康提醒 -->
|
||||
<view class="reminder-section" v-if="healthReminders.length > 0">
|
||||
<text class="nav-section-title">健康提醒</text>
|
||||
<view class="reminder-list">
|
||||
<view class="reminder-item" v-for="(reminder, index) in healthReminders" :key="index">
|
||||
<text class="reminder-icon">{{ reminder.icon }}</text>
|
||||
<view class="reminder-content">
|
||||
<text class="reminder-title">{{ reminder.title }}</text>
|
||||
<text class="reminder-desc">{{ reminder.description }}</text>
|
||||
</view>
|
||||
<button class="reminder-btn" @click="handleReminder(reminder)">{{ reminder.action }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据统计弹窗 -->
|
||||
<view class="stats-modal" v-if="showStats" @click="closeStats">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">数据统计</text>
|
||||
<button class="close-btn" @click="closeStats">×</button>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<view class="stats-grid">
|
||||
<view class="stats-item" v-for="(stat, index) in dataStats" :key="index">
|
||||
<text class="stats-label">{{ stat.label }}</text>
|
||||
<text class="stats-value">{{ stat.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import AkSupa from '@/components/supadb/aksupa.uts'
|
||||
|
||||
// 响应式数据
|
||||
const deviceStatus = ref({
|
||||
active: 0,
|
||||
total: 0
|
||||
})
|
||||
const todayData = ref({
|
||||
count: 0
|
||||
})
|
||||
const realtimeStatus = ref<boolean>(false)
|
||||
const recentData = ref<Array<UTSJSONObject>>([])
|
||||
const healthReminders = ref<Array<UTSJSONObject>>([])
|
||||
const showStats = ref<boolean>(false)
|
||||
const dataStats = ref<Array<UTSJSONObject>>([])
|
||||
|
||||
const userId = 'eed3824b-bba1-4309-8048-19d17367c084'
|
||||
let supa: AkSupa | null = null
|
||||
|
||||
onMounted(() => {
|
||||
initSupa()
|
||||
loadDashboardData()
|
||||
})
|
||||
|
||||
function initSupa() {
|
||||
const supaUrl = getApp().globalData.supabaseUrl ?? ''
|
||||
const supaKey = getApp().globalData.supabaseKey ?? ''
|
||||
supa = new AkSupa(supaUrl, supaKey)
|
||||
}
|
||||
|
||||
async function loadDashboardData() {
|
||||
await loadDeviceStatus()
|
||||
await loadTodayData()
|
||||
await loadRecentData()
|
||||
await loadHealthReminders()
|
||||
checkRealtimeStatus()
|
||||
}
|
||||
|
||||
async function loadDeviceStatus() {
|
||||
if (supa === null) return
|
||||
|
||||
try {
|
||||
const result = await supa.from('ak_devices')
|
||||
.eq('user_id', userId)
|
||||
.execute()
|
||||
|
||||
if (result.data !== null && Array.isArray(result.data)) {
|
||||
const devices = result.data as Array<UTSJSONObject>
|
||||
deviceStatus.value.total = devices.length
|
||||
deviceStatus.value.active = devices.filter(d => d.getString('status') === 'active').length
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('加载设备状态失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTodayData() {
|
||||
if (supa === null) return
|
||||
|
||||
try {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const todayStr = today.toISOString()
|
||||
|
||||
const result = await supa.from('ss_sensor_measurements')
|
||||
.eq('user_id', userId)
|
||||
.gte('measured_at', todayStr)
|
||||
.execute()
|
||||
|
||||
if (result.data !== null && Array.isArray(result.data)) {
|
||||
todayData.value.count = result.data.length
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('加载今日数据失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentData() {
|
||||
if (supa === null) return
|
||||
|
||||
try {
|
||||
const result = await supa.from('ss_sensor_measurements')
|
||||
.eq('user_id', userId)
|
||||
.order('measured_at', { ascending: false })
|
||||
.limit(10)
|
||||
.execute()
|
||||
|
||||
if (result.data !== null && Array.isArray(result.data)) {
|
||||
recentData.value = result.data as Array<UTSJSONObject>
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('加载最近数据失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHealthReminders() {
|
||||
// 模拟健康提醒数据
|
||||
const reminders: Array<UTSJSONObject> = []
|
||||
|
||||
// 检查是否有异常数据需要关注
|
||||
if (recentData.value.length > 0) {
|
||||
const latestHeartRate = recentData.value.find(item =>
|
||||
item.getString('measurement_type') === 'heart_rate'
|
||||
)
|
||||
|
||||
if (latestHeartRate !== null) {
|
||||
const rawData = latestHeartRate.getJSON('raw_data')
|
||||
if (rawData !== null) {
|
||||
const bpm = rawData.getNumber('bpm') ?? 0
|
||||
if (bpm > 100) {
|
||||
const reminder = new UTSJSONObject()
|
||||
reminder.set('icon', '❤️')
|
||||
reminder.set('title', '心率偏高提醒')
|
||||
reminder.set('description', `最近测量心率${bpm}次/分钟,建议注意休息`)
|
||||
reminder.set('action', '查看详情')
|
||||
reminder.set('type', 'heart_rate_high')
|
||||
reminders.push(reminder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加运动提醒
|
||||
const stepsToday = recentData.value.filter(item =>
|
||||
item.getString('measurement_type') === 'steps'
|
||||
)
|
||||
|
||||
if (stepsToday.length === 0) {
|
||||
const reminder = new UTSJSONObject()
|
||||
reminder.set('icon', '🚶')
|
||||
reminder.set('title', '运动提醒')
|
||||
reminder.set('description', '今天还没有运动记录,建议进行适量运动')
|
||||
reminder.set('action', '开始运动')
|
||||
reminder.set('type', 'exercise_reminder')
|
||||
reminders.push(reminder)
|
||||
}
|
||||
}
|
||||
|
||||
healthReminders.value = reminders
|
||||
}
|
||||
|
||||
function checkRealtimeStatus() {
|
||||
// 模拟检查实时监控状态
|
||||
realtimeStatus.value = true
|
||||
}
|
||||
|
||||
function navigateTo(url: string) {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
function viewDetail(item: UTSJSONObject) {
|
||||
const id = item.getString('id') ?? ''
|
||||
uni.navigateTo({
|
||||
url: `/pages/sense/detail?id=${id}`
|
||||
})
|
||||
}
|
||||
|
||||
async function exportAllData() {
|
||||
uni.showLoading({
|
||||
title: '准备导出...'
|
||||
})
|
||||
|
||||
// 模拟导出过程
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: '数据导出完成',
|
||||
icon: 'success'
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
async function syncData() {
|
||||
uni.showLoading({
|
||||
title: '同步数据中...'
|
||||
})
|
||||
|
||||
try {
|
||||
// 模拟数据同步
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 1500)
|
||||
})
|
||||
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: '数据同步完成',
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
// 刷新数据
|
||||
await loadDashboardData()
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: '同步失败',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function showDataStats() {
|
||||
// 构建统计数据
|
||||
const stats: Array<UTSJSONObject> = []
|
||||
|
||||
const totalMeasurements = new UTSJSONObject()
|
||||
totalMeasurements.set('label', '总测量次数')
|
||||
totalMeasurements.set('value', recentData.value.length.toString())
|
||||
stats.push(totalMeasurements)
|
||||
|
||||
const dataTypes = new Set<string>()
|
||||
recentData.value.forEach(item => {
|
||||
const type = item.getString('measurement_type') ?? ''
|
||||
if (type !== '') {
|
||||
dataTypes.add(type)
|
||||
}
|
||||
})
|
||||
|
||||
const uniqueTypes = new UTSJSONObject()
|
||||
uniqueTypes.set('label', '数据类型')
|
||||
uniqueTypes.set('value', dataTypes.size.toString())
|
||||
stats.push(uniqueTypes)
|
||||
|
||||
const activeDevices = new UTSJSONObject()
|
||||
activeDevices.set('label', '活跃设备')
|
||||
activeDevices.set('value', deviceStatus.value.active.toString())
|
||||
stats.push(activeDevices)
|
||||
|
||||
dataStats.value = stats
|
||||
showStats.value = true
|
||||
}
|
||||
|
||||
function closeStats() {
|
||||
showStats.value = false
|
||||
}
|
||||
|
||||
function handleReminder(reminder: UTSJSONObject) {
|
||||
const type = reminder.getString('type') ?? ''
|
||||
|
||||
if (type === 'heart_rate_high') {
|
||||
navigateTo('/pages/sense/analysis')
|
||||
} else if (type === 'exercise_reminder') {
|
||||
navigateTo('/pages/sense/index')
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
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 formatValue(item: UTSJSONObject): string {
|
||||
const rawData = item.getJSON('raw_data')
|
||||
const type = item.getString('measurement_type') ?? ''
|
||||
|
||||
if (rawData === null) return '--'
|
||||
|
||||
if (type === 'heart_rate') {
|
||||
const bpm = rawData.getNumber('bpm') ?? 0
|
||||
return bpm.toString() + ' bpm'
|
||||
} 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}`
|
||||
}
|
||||
|
||||
return '--'
|
||||
}
|
||||
|
||||
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 < 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 days = Math.floor(diff / 86400000)
|
||||
return `${days}天前`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deviceStatus,
|
||||
todayData,
|
||||
realtimeStatus,
|
||||
recentData,
|
||||
healthReminders,
|
||||
showStats,
|
||||
dataStats,
|
||||
navigateTo,
|
||||
viewDetail,
|
||||
exportAllData,
|
||||
syncData,
|
||||
showDataStats,
|
||||
closeStats,
|
||||
handleReminder,
|
||||
getTypeLabel,
|
||||
formatValue,
|
||||
formatTime
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-container {
|
||||
flex: 1;
|
||||
padding: 20rpx;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
align-items: center;
|
||||
margin-bottom: 30rpx;
|
||||
padding: 40rpx 20rpx;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.status-cards {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
margin: 0 8rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 48rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.card-unit {
|
||||
font-size: 22rpx;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.main-nav, .device-section, .data-section, .recent-section, .reminder-section {
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.nav-grid {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 48%;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.nav-item.large {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.nav-icon-container {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 60rpx;
|
||||
background-color: #f0f8ff;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.nav-item.large .nav-icon-container {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 64rpx;
|
||||
}
|
||||
|
||||
.nav-item.large .nav-icon {
|
||||
font-size: 48rpx;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.nav-item.large .nav-title {
|
||||
font-size: 32rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.nav-desc {
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.nav-item.large .nav-desc {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.data-options {
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.data-btn {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
margin: 0 8rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 48rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 26rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.recent-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
padding: 12rpx 20rpx;
|
||||
background-color: #409EFF;
|
||||
color: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
font-size: 24rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.recent-list {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.recent-item {
|
||||
width: 240rpx;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
margin-right: 16rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.recent-type {
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.recent-value {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.recent-time {
|
||||
font-size: 22rpx;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.reminder-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.reminder-item {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.reminder-icon {
|
||||
font-size: 48rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.reminder-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.reminder-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.reminder-desc {
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.reminder-btn {
|
||||
padding: 16rpx 24rpx;
|
||||
background-color: #409EFF;
|
||||
color: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
font-size: 24rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.stats-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%;
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 32rpx;
|
||||
border-bottom: 2rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.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 {
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.stats-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #409EFF;
|
||||
}
|
||||
</style>
|
||||
507
pages/sense/senseDataService.uts
Normal file
507
pages/sense/senseDataService.uts
Normal file
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* 传感器数据服务 - 使用 executeAs<T> 方式进行类型转换
|
||||
* 减少UI层的类型转换问题
|
||||
*/
|
||||
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import type { AkReqResponse } from '@/uni_modules/ak-req/index.uts'
|
||||
import type {
|
||||
SensorMeasurement,
|
||||
SensorAnalysisResult,
|
||||
DeviceInfo
|
||||
} from './types.uts'
|
||||
|
||||
// Helper function to create error response factories
|
||||
function createErrorResponseFactory<T>(defaults: T) {
|
||||
return (message: string): AkReqResponse<T> => ({
|
||||
status: 500,
|
||||
data: defaults,
|
||||
headers: {},
|
||||
error: new UniError('SenseDataService', -1, message),
|
||||
total: null,
|
||||
page: null,
|
||||
limit: null,
|
||||
hasmore: null,
|
||||
origin: null
|
||||
});
|
||||
}
|
||||
|
||||
// 保留原 createErrorResponse 以兼容其它调用(如有)
|
||||
function createErrorResponse<T>(data: T, message: string): AkReqResponse<T> {
|
||||
return createErrorResponseFactory<T>(data)(message);
|
||||
}
|
||||
|
||||
// 查询参数类型
|
||||
export type SensorDataParams = {
|
||||
device_id ?: string | null
|
||||
user_id ?: string | null
|
||||
measurement_type ?: string | null
|
||||
start_date ?: string | null
|
||||
end_date ?: string | null
|
||||
limit ?: number | null
|
||||
offset ?: number | null
|
||||
}
|
||||
|
||||
export type DeviceParams = {
|
||||
user_id ?: string | null
|
||||
device_type ?: string | null
|
||||
status ?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 传感器数据服务类
|
||||
*/
|
||||
export class SenseDataService {
|
||||
/**
|
||||
* 获取传感器测量数据列表
|
||||
*/
|
||||
static async getMeasurements(params : SensorDataParams) : Promise<AkReqResponse<Array<SensorMeasurement>>> {
|
||||
try {
|
||||
let query = supa
|
||||
.from('ss_sensor_measurements')
|
||||
.select('*', {})
|
||||
.order('measured_at', { ascending: false })
|
||||
// 添加筛选条件
|
||||
const deviceId = params.device_id
|
||||
if (deviceId != null) {
|
||||
query = query.eq('device_id', deviceId)
|
||||
}
|
||||
const userId = params.user_id
|
||||
if (userId != null && userId !== '') {
|
||||
query = query.eq('user_id', userId)
|
||||
}
|
||||
const measurementType = params.measurement_type
|
||||
if (measurementType != null) {
|
||||
query = query.eq('measurement_type', measurementType)
|
||||
}
|
||||
const startDate = params.start_date
|
||||
if (startDate != null) {
|
||||
query = query.gte('measured_at', startDate)
|
||||
}
|
||||
const endDate = params.end_date
|
||||
if (endDate != null) {
|
||||
query = query.lte('measured_at', endDate)
|
||||
} // 分页
|
||||
const limit = params.limit ?? 20
|
||||
const offset = params.offset ?? 0
|
||||
console.log(limit, offset)
|
||||
|
||||
// 先设置 limit,这会自动计算并设置对应的 range
|
||||
query = query.limit(limit)
|
||||
|
||||
// 如果有 offset,需要重新调整 range
|
||||
if (offset > 0) {
|
||||
query = query.range(offset, offset + limit - 1)
|
||||
}
|
||||
|
||||
// 如果 limit 为 1,使用 single() 方法优化查询
|
||||
if (limit == 1) {
|
||||
query = query.single()
|
||||
}
|
||||
const response = await query.executeAs<SensorMeasurement>()
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
const errorMsg = typeof error === 'string' ? error : error?.message ?? '获取传感器数据失败';
|
||||
const empty: SensorMeasurement = {
|
||||
id: '', device_id: '', user_id: '', measurement_type: '', measured_at: '', unit: '', raw_data: new UTSJSONObject(), created_at: ''
|
||||
};
|
||||
const errorSensorMeasurement = createErrorResponseFactory(empty);
|
||||
const errorSensorMeasurementArr = createErrorResponseFactory([] as SensorMeasurement[]);
|
||||
if ((params.limit ?? 20) === 1) {
|
||||
return errorSensorMeasurement(errorMsg);
|
||||
} else {
|
||||
return errorSensorMeasurementArr(errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单条传感器测量数据
|
||||
*/
|
||||
static async getMeasurementById(id : string) : Promise<AkReqResponse<SensorMeasurement>> {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ss_sensor_measurements')
|
||||
.select('*', {})
|
||||
.eq('id', id)
|
||||
.single()
|
||||
.executeAs<SensorMeasurement>()
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
const errorMsg = typeof error === 'string' ? error : error?.message ?? '获取传感器数据详情失败';
|
||||
const empty: SensorMeasurement = {
|
||||
id: '', device_id: '', user_id: '', measurement_type: '', measured_at: '', unit: '', raw_data: new UTSJSONObject(), created_at: ''
|
||||
};
|
||||
return createErrorResponseFactory(empty)(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最新的传感器测量数据(单条记录)
|
||||
*/
|
||||
static async getLatestMeasurement(params : SensorDataParams) : Promise<AkReqResponse<SensorMeasurement>> {
|
||||
try {
|
||||
let query = supa
|
||||
.from('ss_sensor_measurements')
|
||||
.select('*', {})
|
||||
.order('measured_at', { ascending: false })
|
||||
|
||||
// 添加筛选条件
|
||||
const deviceId = params.device_id
|
||||
if (deviceId != null) {
|
||||
query = query.eq('device_id', deviceId)
|
||||
}
|
||||
const userId = params.user_id
|
||||
if (userId != null) {
|
||||
query = query.eq('user_id', userId)
|
||||
}
|
||||
const measurementType = params.measurement_type
|
||||
if (measurementType != null) {
|
||||
query = query.eq('measurement_type', measurementType)
|
||||
}
|
||||
|
||||
// 使用 single() 方法获取单条最新记录
|
||||
const response = await query.single().executeAs<SensorMeasurement>()
|
||||
|
||||
|
||||
return response
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = typeof error === 'string' ? error : error?.message ?? '获取最新传感器数据失败';
|
||||
const empty: SensorMeasurement = {
|
||||
id: '', device_id: '', user_id: '', measurement_type: '', measured_at: '', unit: '', raw_data: new UTSJSONObject(), created_at: ''
|
||||
};
|
||||
return createErrorResponseFactory(empty)(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备列表
|
||||
*/
|
||||
static async getDevices(params : DeviceParams) : Promise<AkReqResponse<Array<DeviceInfo>>> {
|
||||
try {
|
||||
let query = supa
|
||||
.from('ak_devices')
|
||||
.select('*', {})
|
||||
.order('bind_time', { ascending: false })
|
||||
// 添加筛选条件
|
||||
const userId = params.user_id
|
||||
if (userId != null) {
|
||||
query = query.eq('user_id', userId)
|
||||
}
|
||||
const deviceType = params.device_type
|
||||
if (deviceType != null) {
|
||||
query = query.eq('device_type', deviceType)
|
||||
}
|
||||
const status = params.status
|
||||
if (status != null) {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
const response = await query.executeAs<DeviceInfo>()
|
||||
|
||||
|
||||
return response
|
||||
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = typeof error === 'string' ? error : error?.message ?? '获取设备列表失败';
|
||||
return createErrorResponseFactory([] as DeviceInfo[])(errorMsg);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取设备详情
|
||||
*/
|
||||
static async getDeviceById(id : string) : Promise<AkReqResponse<Array<DeviceInfo>>> {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ak_devices')
|
||||
.select('*', {})
|
||||
.eq('id', id)
|
||||
.single()
|
||||
.executeAs<DeviceInfo>()
|
||||
|
||||
console.log(response)
|
||||
return response
|
||||
} catch (error) {
|
||||
const errorMsg = typeof error === 'string' ? error : error?.message ?? '获取设备详情失败';
|
||||
const empty: DeviceInfo = {
|
||||
id: '', user_id: '', device_type: '', device_name: '', device_mac: '', bind_time: '', status: '', extra: new UTSJSONObject()
|
||||
};
|
||||
return createErrorResponseFactory(empty)(errorMsg);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取分析结果列表
|
||||
*/
|
||||
static async getAnalysisResults(user_id : string, analysis_type ?: string | null) : Promise<AkReqResponse<Array<SensorAnalysisResult>>> {
|
||||
try {
|
||||
let query = supa
|
||||
.from('ak_sensor_analysis')
|
||||
.select('*', {})
|
||||
.eq('user_id', user_id)
|
||||
.order('created_at', { ascending: false })
|
||||
const analysisType = analysis_type
|
||||
if (analysisType != null) {
|
||||
query = query.eq('analysis_type', analysisType)
|
||||
}
|
||||
|
||||
const response = await query.executeAs<SensorAnalysisResult>()
|
||||
return response
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = typeof error === 'string' ? error : error?.message ?? '获取分析结果失败';
|
||||
return createErrorResponseFactory([] as SensorAnalysisResult[])(errorMsg);
|
||||
}
|
||||
}/**
|
||||
* 创建传感器测量数据 - 支持单个或批量
|
||||
*/
|
||||
static async createMeasurement(data : UTSJSONObject | Array<UTSJSONObject>) : Promise<AkReqResponse<SensorMeasurement | Array<SensorMeasurement>>> {
|
||||
return await this.insertMeasurementData(data)
|
||||
}/**
|
||||
* 批量创建传感器测量数据(用于模拟器)
|
||||
* @deprecated 使用 createMeasurement 方法,它现在支持批量插入
|
||||
*/
|
||||
static async createMeasurements(dataList : Array<UTSJSONObject>) : Promise<AkReqResponse<Array<SensorMeasurement>>> {
|
||||
const result = await this.createMeasurement(dataList)
|
||||
return result as AkReqResponse<Array<SensorMeasurement>>
|
||||
}
|
||||
/**
|
||||
* 绑定设备
|
||||
*/
|
||||
static async bindDevice(deviceData : UTSJSONObject) : Promise<AkReqResponse<DeviceInfo>> {
|
||||
try {
|
||||
const data = new UTSJSONObject()
|
||||
// 复制传入的数据
|
||||
const keys = UTSJSONObject.keys(data)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i]
|
||||
data.set(key, deviceData.get(key))
|
||||
}
|
||||
data.set('bind_time', new Date().toISOString())
|
||||
data.set('status', 'online')
|
||||
const response = await supa
|
||||
.from('ak_devices')
|
||||
.insert(data)
|
||||
.select('*', {})
|
||||
.single()
|
||||
.executeAs<DeviceInfo>()
|
||||
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
const errorMsg = typeof error === 'string' ? error : error?.message ?? '绑定设备失败';
|
||||
const empty: DeviceInfo = {
|
||||
id: '', user_id: '', device_type: '', device_name: '', device_mac: '', bind_time: '', status: '', extra: new UTSJSONObject()
|
||||
};
|
||||
return createErrorResponseFactory(empty)(errorMsg);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 更新设备状态
|
||||
*/
|
||||
static async updateDeviceStatus(device_id : string, status : string) : Promise<AkReqResponse<DeviceInfo>> {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ak_devices')
|
||||
.update({ status: status })
|
||||
.eq('id', device_id)
|
||||
.select('*', {})
|
||||
.single()
|
||||
.executeAs<DeviceInfo>()
|
||||
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
const errorMsg = typeof error === 'string' ? error : error?.message ?? '更新设备状态失败';
|
||||
const empty: DeviceInfo = {
|
||||
id: '', user_id: '', device_type: '', device_name: '', device_mac: '', bind_time: '', status: '', extra: new UTSJSONObject()
|
||||
};
|
||||
return createErrorResponseFactory(empty)(errorMsg);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 解绑设备
|
||||
*/
|
||||
static async unbindDevice(device_id : string) : Promise<AkReqResponse<boolean>> {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ak_devices')
|
||||
.delete()
|
||||
.eq('id', device_id)
|
||||
.execute()
|
||||
|
||||
if (response.error === null) {
|
||||
return {
|
||||
status: 200,
|
||||
data: true,
|
||||
headers: {},
|
||||
error: null,
|
||||
total: null,
|
||||
page: null,
|
||||
limit: null,
|
||||
hasmore: null,
|
||||
origin: null
|
||||
}
|
||||
} else {
|
||||
return createErrorResponseFactory(false)(response.error?.message ?? '设备解绑失败');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = typeof error === 'string' ? error : error?.message ?? '设备解绑失败';
|
||||
return createErrorResponseFactory(false)(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新设备信息
|
||||
*/
|
||||
static async updateDevice(device_id : string, deviceData : UTSJSONObject) : Promise<AkReqResponse<DeviceInfo>> {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ak_devices')
|
||||
.update(deviceData)
|
||||
.eq('id', device_id)
|
||||
.select('*', {})
|
||||
.single()
|
||||
.executeAs<DeviceInfo>()
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
const errorMsg = typeof error === 'string' ? error : error?.message ?? '更新设备信息失败';
|
||||
const empty: DeviceInfo = {
|
||||
id: '', user_id: '', device_type: '', device_name: '', device_mac: '', bind_time: '', status: '', extra: new UTSJSONObject()
|
||||
};
|
||||
return createErrorResponseFactory(empty)(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的插入方法 - 支持单个或批量插入
|
||||
* @param data 单个对象或对象数组
|
||||
* @returns 插入结果
|
||||
*/
|
||||
static async insertMeasurements(data : UTSJSONObject | Array<UTSJSONObject>) : Promise<AkReqResponse<SensorMeasurement | Array<SensorMeasurement>>> {
|
||||
try {
|
||||
let processedData : UTSJSONObject | Array<UTSJSONObject>
|
||||
let isBatch = false
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
// 批量插入
|
||||
isBatch = true
|
||||
const measurementDataList : Array<UTSJSONObject> = []
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const item = data[i]
|
||||
const measurementData = new UTSJSONObject()
|
||||
// 复制传入的数据
|
||||
const keys = UTSJSONObject.keys(item)
|
||||
for (let j = 0; j < keys.length; j++) {
|
||||
const key = keys[j]
|
||||
measurementData.set(key, item.get(key))
|
||||
}
|
||||
measurementData.set('created_at', new Date().toISOString())
|
||||
measurementDataList.push(measurementData)
|
||||
}
|
||||
processedData = measurementDataList
|
||||
} else {
|
||||
// 单个插入
|
||||
const measurementData = new UTSJSONObject()
|
||||
// 复制传入的数据
|
||||
const keys = UTSJSONObject.keys(data)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i]
|
||||
measurementData.set(key, data.get(key))
|
||||
}
|
||||
measurementData.set('created_at', new Date().toISOString())
|
||||
processedData = measurementData
|
||||
}
|
||||
let query = supa
|
||||
.from('ss_sensor_measurements')
|
||||
.insert(processedData)
|
||||
.select('*', {})
|
||||
|
||||
// 根据是否批量决定是否使用 single()
|
||||
if (!isBatch) {
|
||||
query = query.single()
|
||||
}
|
||||
|
||||
if (isBatch) {
|
||||
const response = await query.executeAs<SensorMeasurement>()
|
||||
return response
|
||||
} else {
|
||||
const response = await query.executeAs<SensorMeasurement>()
|
||||
|
||||
return response
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = typeof error === 'string' ? error : error?.message ?? (Array.isArray(data) ? '批量创建传感器数据失败' : '创建传感器数据失败');
|
||||
const empty: SensorMeasurement = {
|
||||
id: '', device_id: '', user_id: '', measurement_type: '', measured_at: '', unit: '', raw_data: new UTSJSONObject(), created_at: ''
|
||||
};
|
||||
const errorSensorMeasurement = createErrorResponseFactory(empty);
|
||||
const errorSensorMeasurementArr = createErrorResponseFactory([] as SensorMeasurement[]);
|
||||
if (Array.isArray(data)) {
|
||||
return errorSensorMeasurementArr(errorMsg) as AkReqResponse<SensorMeasurement | Array<SensorMeasurement>>;
|
||||
} else {
|
||||
return errorSensorMeasurement(errorMsg) as AkReqResponse<SensorMeasurement | Array<SensorMeasurement>>;
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 传感器测量数据插入方法 - 支持单个或批量插入
|
||||
* @param data 单个对象或对象数组
|
||||
* @returns 插入结果
|
||||
*/
|
||||
static async insertMeasurementData(data : UTSJSONObject | Array<UTSJSONObject>) : Promise<AkReqResponse<Array<SensorMeasurement>>> {
|
||||
try {
|
||||
let processedData : UTSJSONObject | Array<UTSJSONObject>
|
||||
let isBatch = Array.isArray(data)
|
||||
|
||||
if (isBatch) {
|
||||
// 批量插入
|
||||
const dataList : Array<UTSJSONObject> = []
|
||||
const dataArray = data as Array<UTSJSONObject>
|
||||
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const item = dataArray[i]
|
||||
const processedItem = new UTSJSONObject()
|
||||
// 复制传入的数据
|
||||
const keys = UTSJSONObject.keys(item)
|
||||
for (let j = 0; j < keys.length; j++) {
|
||||
const key = keys[j]
|
||||
processedItem.set(key, item.get(key))
|
||||
}
|
||||
processedItem.set('created_at', new Date().toISOString())
|
||||
dataList.push(processedItem)
|
||||
}
|
||||
processedData = dataList
|
||||
} else {
|
||||
// 单个插入
|
||||
const singleData = data as UTSJSONObject
|
||||
const processedItem = new UTSJSONObject()
|
||||
// 复制传入的数据
|
||||
const keys = UTSJSONObject.keys(singleData)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i]
|
||||
processedItem.set(key, singleData.get(key))
|
||||
}
|
||||
processedItem.set('created_at', new Date().toISOString())
|
||||
processedData = processedItem
|
||||
}
|
||||
let query = supa
|
||||
.from('ss_sensor_measurements')
|
||||
.insert(processedData)
|
||||
|
||||
|
||||
const response = await query.executeAs<SensorMeasurement>()
|
||||
|
||||
return response
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = typeof error === 'string' ? error : error?.message ?? '插入传感器数据失败';
|
||||
return createErrorResponseFactory([] as SensorMeasurement[])(errorMsg) as AkReqResponse<SensorMeasurement | Array<SensorMeasurement>>;
|
||||
}
|
||||
}
|
||||
}
|
||||
978
pages/sense/settings.uvue
Normal file
978
pages/sense/settings.uvue
Normal file
@@ -0,0 +1,978 @@
|
||||
<template>
|
||||
<view class="settings-container">
|
||||
<!-- 头部导航 -->
|
||||
<view class="header">
|
||||
<button class="back-btn" @click="goBack">
|
||||
<text class="back-icon">←</text>
|
||||
</button>
|
||||
<text class="title">传感器设置</text>
|
||||
<button class="save-btn" @click="saveSettings">保存</button>
|
||||
</view>
|
||||
|
||||
<!-- 通用设置 -->
|
||||
<view class="section">
|
||||
<text class="section-title">通用设置</text>
|
||||
<view class="setting-item">
|
||||
<text class="setting-label">实时数据推送</text>
|
||||
<switch :checked="settings.realtime_enabled" @change="onRealtimeChange" />
|
||||
</view>
|
||||
<view class="setting-item">
|
||||
<text class="setting-label">数据自动备份</text>
|
||||
<switch :checked="settings.auto_backup" @change="onAutoBackupChange" />
|
||||
</view>
|
||||
<view class="setting-item">
|
||||
<text class="setting-label">异常检测</text>
|
||||
<switch :checked="settings.anomaly_detection" @change="onAnomalyDetectionChange" />
|
||||
</view>
|
||||
<view class="setting-item">
|
||||
<text class="setting-label">AI分析</text>
|
||||
<switch :checked="settings.ai_analysis" @change="onAIAnalysisChange" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据采集频率 -->
|
||||
<view class="section">
|
||||
<text class="section-title">数据采集频率</text>
|
||||
<view class="frequency-grid">
|
||||
<view class="frequency-item" v-for="(sensor, index) in sensorFrequencies" :key="index">
|
||||
<text class="sensor-name">{{ sensor.name }}</text>
|
||||
<button class="picker-button" @click="showFrequencyPicker(index)">
|
||||
<view class="picker-view">
|
||||
<text>{{ frequencyOptions[sensor.frequency_index] }}</text>
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 阈值设置 -->
|
||||
<view class="section">
|
||||
<text class="section-title">异常阈值设置</text>
|
||||
<view class="threshold-list">
|
||||
<view class="threshold-item" v-for="(threshold, index) in thresholds" :key="index">
|
||||
<text class="threshold-label">{{ threshold.name }}</text>
|
||||
<view class="threshold-inputs">
|
||||
<view class="input-group">
|
||||
<text class="input-label">最小值</text>
|
||||
<input class="threshold-input" type="number" v-model="threshold.min_value" />
|
||||
</view>
|
||||
<view class="input-group">
|
||||
<text class="input-label">最大值</text>
|
||||
<input class="threshold-input" type="number" v-model="threshold.max_value" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 通知设置 -->
|
||||
<view class="section">
|
||||
<text class="section-title">通知设置</text>
|
||||
<view class="notification-item">
|
||||
<text class="setting-label">异常警报</text>
|
||||
<switch :checked="notifications.anomaly_alert" @change="onAnomalyAlertChange" />
|
||||
</view>
|
||||
<view class="notification-item">
|
||||
<text class="setting-label">每日报告</text>
|
||||
<switch :checked="notifications.daily_report" @change="onDailyReportChange" />
|
||||
</view>
|
||||
<view class="notification-item">
|
||||
<text class="setting-label">设备离线提醒</text>
|
||||
<switch :checked="notifications.device_offline" @change="onDeviceOfflineChange" />
|
||||
</view>
|
||||
<view class="notification-item" v-if="notifications.daily_report">
|
||||
<text class="setting-label">报告发送时间</text>
|
||||
<input class="time-input" type="time" :value="notifications.report_time" @input="onReportTimeInput" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据管理 -->
|
||||
<view class="section">
|
||||
<text class="section-title">数据管理</text>
|
||||
<view class="data-management">
|
||||
<view class="management-item">
|
||||
<text class="setting-label">本地数据保留时间</text>
|
||||
<button class="picker-button" @click="showRetentionPicker">
|
||||
<view class="picker-view">
|
||||
<text>{{ retentionOptions[dataManagement.retention_index] }}</text>
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
<view class="management-item">
|
||||
<text class="setting-label">云端同步</text>
|
||||
<switch :checked="dataManagement.cloud_sync" @change="onCloudSyncChange" />
|
||||
</view>
|
||||
<view class="management-item">
|
||||
<text class="setting-label">数据压缩</text>
|
||||
<switch :checked="dataManagement.data_compression" @change="onDataCompressionChange" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 隐私设置 -->
|
||||
<view class="section">
|
||||
<text class="section-title">隐私设置</text>
|
||||
<view class="privacy-item">
|
||||
<text class="setting-label">数据加密</text>
|
||||
<switch :checked="privacy.data_encryption" @change="onDataEncryptionChange" />
|
||||
</view>
|
||||
<view class="privacy-item">
|
||||
<text class="setting-label">匿名统计</text>
|
||||
<switch :checked="privacy.anonymous_stats" @change="onAnonymousStatsChange" />
|
||||
</view>
|
||||
<view class="privacy-item">
|
||||
<text class="setting-label">位置数据收集</text>
|
||||
<switch :checked="privacy.location_data" @change="onLocationDataChange" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 设备校准 -->
|
||||
<view class="section">
|
||||
<text class="section-title">设备校准</text>
|
||||
<view class="calibration-list">
|
||||
<button class="calibration-btn" @click="calibrateHeartRate">心率传感器校准</button>
|
||||
<button class="calibration-btn" @click="calibrateSteps">步数传感器校准</button>
|
||||
<button class="calibration-btn" @click="calibrateBloodPressure">血压计校准</button>
|
||||
<button class="calibration-btn" @click="calibrateTemperature">体温计校准</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据导出 -->
|
||||
<view class="section">
|
||||
<text class="section-title">数据导出</text>
|
||||
<view class="export-options">
|
||||
<button class="export-btn" @click="exportData('json')">导出为JSON</button>
|
||||
<button class="export-btn" @click="exportData('csv')">导出为CSV</button>
|
||||
<button class="export-btn" @click="exportData('pdf')">导出为PDF报告</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 重置选项 -->
|
||||
<view class="section">
|
||||
<text class="section-title">重置选项</text>
|
||||
<view class="reset-options">
|
||||
<button class="reset-btn" @click="resetSettings">恢复默认设置</button>
|
||||
<button class="clear-btn" @click="clearAllData">清除所有数据</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 校准弹窗 -->
|
||||
<view class="calibration-modal" v-if="showCalibration" @click="closeCalibration">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">{{ calibrationType }}校准</text>
|
||||
<button class="close-btn" @click="closeCalibration">×</button>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<text class="calibration-instruction">{{ calibrationInstruction }}</text>
|
||||
<view class="calibration-progress" v-if="calibrationProgress > 0">
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: calibrationProgress + '%' }"></view>
|
||||
</view>
|
||||
<text class="progress-text">{{ calibrationProgress }}%</text>
|
||||
</view>
|
||||
<view class="calibration-actions">
|
||||
<button class="start-btn" v-if="calibrationProgress === 0"
|
||||
@click="startCalibration">开始校准</button>
|
||||
<button class="stop-btn" v-else @click="stopCalibration">停止校准</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import type {
|
||||
UserSettings,
|
||||
NotificationSettings,
|
||||
DataManagementSettings,
|
||||
PrivacySettings,
|
||||
SensorFrequency,
|
||||
SensorThreshold,
|
||||
UserSettingsRecord,
|
||||
SensorConfigItem,
|
||||
ThresholdConfigItem
|
||||
} from './types.uts'
|
||||
|
||||
// 响应式数据
|
||||
const settings = ref<UserSettings>({
|
||||
realtime_enabled: true,
|
||||
auto_backup: true,
|
||||
anomaly_detection: true,
|
||||
ai_analysis: false
|
||||
})
|
||||
|
||||
const sensorFrequencies = ref<SensorFrequency[]>([])
|
||||
const thresholds = ref<SensorThreshold[]>([])
|
||||
|
||||
const notifications = ref<NotificationSettings>({
|
||||
anomaly_alert: true,
|
||||
daily_report: false,
|
||||
device_offline: true,
|
||||
report_time: '09:00'
|
||||
})
|
||||
|
||||
const dataManagement = ref<DataManagementSettings>({
|
||||
retention_index: 2,
|
||||
cloud_sync: true,
|
||||
data_compression: false
|
||||
})
|
||||
|
||||
const privacy = ref<PrivacySettings>({
|
||||
data_encryption: true,
|
||||
anonymous_stats: false,
|
||||
location_data: false
|
||||
})
|
||||
|
||||
const showCalibration = ref<boolean>(false)
|
||||
const calibrationType = ref<string>('')
|
||||
const calibrationInstruction = ref<string>('')
|
||||
const calibrationProgress = ref<number>(0)
|
||||
|
||||
// 选项数据
|
||||
const frequencyOptions = ['关闭', '1分钟', '5分钟', '15分钟', '30分钟', '1小时']
|
||||
const retentionOptions = ['7天', '30天', '90天', '1年', '永久保存']
|
||||
|
||||
const userId = 'eed3824b-bba1-4309-8048-19d17367c084'
|
||||
let calibrationTimer : Number = 0
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const result = await supa.from('ak_training_intensity_settings')
|
||||
.eq('user_id', userId)
|
||||
.eq('setting_category', 'sensor')
|
||||
.executeAs<Array<UserSettingsRecord>>()
|
||||
if (result.data !== null && Array.isArray(result.data)) {
|
||||
const dataArray = result.data as Array<any>
|
||||
if (dataArray.length > 0) {
|
||||
const settingsRecord = dataArray[0] as UTSJSONObject
|
||||
const settingValue = settingsRecord.getJSON('setting_value')
|
||||
if (settingValue !== null) {
|
||||
// 更新设置
|
||||
settings.value.realtime_enabled = settingValue.getBoolean('realtime_enabled') ?? true
|
||||
settings.value.auto_backup = settingValue.getBoolean('auto_backup') ?? true
|
||||
settings.value.anomaly_detection = settingValue.getBoolean('anomaly_detection') ?? true
|
||||
settings.value.ai_analysis = settingValue.getBoolean('ai_analysis') ?? false
|
||||
|
||||
// 更新通知设置
|
||||
const notificationData = settingValue.getJSON('notifications')
|
||||
if (notificationData !== null) {
|
||||
notifications.value.anomaly_alert = notificationData.getBoolean('anomaly_alert') ?? true
|
||||
notifications.value.daily_report = notificationData.getBoolean('daily_report') ?? false
|
||||
notifications.value.device_offline = notificationData.getBoolean('device_offline') ?? true
|
||||
notifications.value.report_time = notificationData.getString('report_time') ?? '09:00'
|
||||
}
|
||||
|
||||
// 更新数据管理设置
|
||||
const dataManagementData = settingValue.getJSON('data_management')
|
||||
if (dataManagementData !== null) {
|
||||
dataManagement.value.retention_index = dataManagementData.getNumber('retention_index')?.toInt() ?? 2
|
||||
dataManagement.value.cloud_sync = dataManagementData.getBoolean('cloud_sync') ?? true
|
||||
dataManagement.value.data_compression = dataManagementData.getBoolean('data_compression') ?? false
|
||||
}
|
||||
|
||||
// 更新隐私设置
|
||||
const privacyData = settingValue.getJSON('privacy')
|
||||
if (privacyData !== null) {
|
||||
privacy.value.data_encryption = privacyData.getBoolean('data_encryption') ?? true
|
||||
privacy.value.anonymous_stats = privacyData.getBoolean('anonymous_stats') ?? false
|
||||
privacy.value.location_data = privacyData.getBoolean('location_data') ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('加载设置失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function initializeSensorFrequencies() {
|
||||
const frequencies : SensorFrequency[] = []
|
||||
|
||||
const sensors : SensorConfigItem[] = [
|
||||
{ name: '心率', key: 'heart_rate', frequency_index: 2 },
|
||||
{ name: '步数', key: 'steps', frequency_index: 3 },
|
||||
{ name: '血氧', key: 'spo2', frequency_index: 4 },
|
||||
{ name: '血压', key: 'blood_pressure', frequency_index: 5 },
|
||||
{ name: '体温', key: 'temperature', frequency_index: 4 },
|
||||
{ name: '位置', key: 'location', frequency_index: 3 }
|
||||
]
|
||||
|
||||
for (let i : Int = 0; i < sensors.length; i++) {
|
||||
const sensor = sensors[i]
|
||||
const freq : SensorFrequency = {
|
||||
name: sensor.name,
|
||||
key: sensor.key,
|
||||
frequency_index: sensor.frequency_index
|
||||
}
|
||||
frequencies.push(freq)
|
||||
}
|
||||
|
||||
sensorFrequencies.value = frequencies
|
||||
} function initializeThresholds() {
|
||||
const thresholdList : SensorThreshold[] = []
|
||||
|
||||
const thresholdConfig : ThresholdConfigItem[] = [
|
||||
{ name: '心率 (bpm)', key: 'heart_rate', min: 60, max: 100 },
|
||||
{ name: '血氧 (%)', key: 'spo2', min: 95, max: 100 },
|
||||
{ name: '收缩压 (mmHg)', key: 'systolic_bp', min: 90, max: 140 },
|
||||
{ name: '舒张压 (mmHg)', key: 'diastolic_bp', min: 60, max: 90 },
|
||||
{ name: '体温 (°C)', key: 'temperature', min: 36.0, max: 37.5 }
|
||||
]
|
||||
|
||||
for (let i : Int = 0; i < thresholdConfig.length; i++) {
|
||||
const config = thresholdConfig[i]
|
||||
const threshold : SensorThreshold = {
|
||||
name: config.name,
|
||||
key: config.key,
|
||||
min_value: config.min.toString(),
|
||||
max_value: config.max.toString()
|
||||
}
|
||||
thresholdList.push(threshold)
|
||||
}
|
||||
|
||||
thresholds.value = thresholdList
|
||||
}
|
||||
async function saveSettings() {
|
||||
try {
|
||||
// 构建设置数据
|
||||
const settingValue = new UTSJSONObject()
|
||||
settingValue.set('realtime_enabled', settings.value.realtime_enabled)
|
||||
settingValue.set('auto_backup', settings.value.auto_backup)
|
||||
settingValue.set('anomaly_detection', settings.value.anomaly_detection)
|
||||
settingValue.set('ai_analysis', settings.value.ai_analysis)
|
||||
settingValue.set('notifications', notifications.value)
|
||||
settingValue.set('data_management', dataManagement.value)
|
||||
settingValue.set('privacy', privacy.value)
|
||||
settingValue.set('sensor_frequencies', sensorFrequencies.value)
|
||||
settingValue.set('thresholds', thresholds.value)
|
||||
const settingData = new UTSJSONObject()
|
||||
settingData.set('user_id', userId)
|
||||
settingData.set('setting_category', 'sensor')
|
||||
settingData.set('setting_key', 'sensor_config')
|
||||
settingData.set('setting_value', settingValue)
|
||||
|
||||
// 先检查是否已存在记录
|
||||
const existingResult = await supa.from('ak_user_settings')
|
||||
.eq('user_id', userId)
|
||||
.eq('setting_category', 'sensor')
|
||||
.executeAs<Array<UserSettingsRecord>>()
|
||||
let result : any
|
||||
if (existingResult.data !== null && Array.isArray(existingResult.data)) {
|
||||
const dataArray = existingResult.data as Array<any>
|
||||
if (dataArray.length > 0) {
|
||||
// 存在记录,使用 update
|
||||
const updateData = new UTSJSONObject()
|
||||
updateData.set('setting_value', settingValue)
|
||||
updateData.set('updated_at', new Date().toISOString())
|
||||
|
||||
result = await supa.from('ak_user_settings')
|
||||
.eq('user_id', userId)
|
||||
.eq('setting_category', 'sensor')
|
||||
.update(updateData)
|
||||
.execute()
|
||||
} else {
|
||||
// 不存在记录,使用 insert
|
||||
settingData.set('created_at', new Date().toISOString())
|
||||
settingData.set('updated_at', new Date().toISOString())
|
||||
|
||||
result = await supa.from('ak_user_settings')
|
||||
.insert(settingData)
|
||||
.execute()
|
||||
}
|
||||
} else {
|
||||
// 不存在记录,使用 insert
|
||||
settingData.set('created_at', new Date().toISOString())
|
||||
settingData.set('updated_at', new Date().toISOString())
|
||||
|
||||
result = await supa.from('ak_user_settings')
|
||||
.insert(settingData)
|
||||
.execute()
|
||||
}
|
||||
if (result.error === null) {
|
||||
uni.showToast({
|
||||
title: '设置保存成功',
|
||||
icon: 'success'
|
||||
})
|
||||
} else {
|
||||
console.log('保存设置失败:', result.error)
|
||||
uni.showToast({
|
||||
title: '保存失败',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('保存设置异常:', e)
|
||||
uni.showToast({
|
||||
title: '保存失败',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 事件处理函数
|
||||
function onRealtimeChange(e : UniSwitchChangeEvent) {
|
||||
settings.value.realtime_enabled = e.detail.value
|
||||
}
|
||||
|
||||
function onAutoBackupChange(e : UniSwitchChangeEvent) {
|
||||
settings.value.auto_backup = e.detail.value
|
||||
}
|
||||
|
||||
function onAnomalyDetectionChange(e : UniSwitchChangeEvent) {
|
||||
settings.value.anomaly_detection = e.detail.value
|
||||
}
|
||||
function onAIAnalysisChange(e : UniSwitchChangeEvent) {
|
||||
settings.value.ai_analysis = e.detail.value
|
||||
}
|
||||
function onFrequencyChange(value : number, index : number) {
|
||||
if (index >= 0 && index < sensorFrequencies.value.length) {
|
||||
sensorFrequencies.value[index].frequency_index = value
|
||||
}
|
||||
}
|
||||
|
||||
function onAnomalyAlertChange(e : UniSwitchChangeEvent) {
|
||||
notifications.value.anomaly_alert = e.detail.value
|
||||
}
|
||||
function onDailyReportChange(e : UniSwitchChangeEvent) {
|
||||
notifications.value.daily_report = e.detail.value
|
||||
}
|
||||
|
||||
function onDeviceOfflineChange(e : UniSwitchChangeEvent) {
|
||||
notifications.value.device_offline = e.detail.value
|
||||
}
|
||||
function onReportTimeChange(value : string) {
|
||||
notifications.value.report_time = value
|
||||
}
|
||||
|
||||
function onRetentionChange(value : number) {
|
||||
dataManagement.value.retention_index = value
|
||||
}
|
||||
|
||||
function onCloudSyncChange(e : UniSwitchChangeEvent) {
|
||||
dataManagement.value.cloud_sync = e.detail.value
|
||||
}
|
||||
|
||||
function onDataCompressionChange(e : UniSwitchChangeEvent) {
|
||||
dataManagement.value.data_compression = e.detail.value
|
||||
}
|
||||
|
||||
function onDataEncryptionChange(e : UniSwitchChangeEvent) {
|
||||
privacy.value.data_encryption = e.detail.value
|
||||
}
|
||||
|
||||
function onAnonymousStatsChange(e : UniSwitchChangeEvent) {
|
||||
privacy.value.anonymous_stats = e.detail.value
|
||||
}
|
||||
|
||||
function onLocationDataChange(e : UniSwitchChangeEvent) {
|
||||
privacy.value.location_data = e.detail.value
|
||||
}
|
||||
|
||||
function showFrequencyPicker(index : number) {
|
||||
uni.showActionSheet({
|
||||
itemList: frequencyOptions,
|
||||
success: (res) => {
|
||||
if (res.tapIndex >= 0) {
|
||||
onFrequencyChange(res.tapIndex, index)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function onReportTimeInput(e : UniInputEvent) {
|
||||
notifications.value.report_time = e.detail.value
|
||||
}
|
||||
|
||||
function showRetentionPicker() {
|
||||
uni.showActionSheet({
|
||||
itemList: retentionOptions,
|
||||
success: (res) => {
|
||||
if (res.tapIndex >= 0) {
|
||||
onRetentionChange(res.tapIndex)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 校准相关函数
|
||||
function startCalibrationProcess(type : string, instruction : string) {
|
||||
calibrationType.value = type
|
||||
calibrationInstruction.value = instruction
|
||||
showCalibration.value = true
|
||||
calibrationProgress.value = 0
|
||||
}
|
||||
|
||||
function calibrateHeartRate() {
|
||||
startCalibrationProcess('心率传感器', '请保持静坐,放松心情,校准过程约需2分钟')
|
||||
}
|
||||
|
||||
function calibrateSteps() {
|
||||
startCalibrationProcess('步数传感器', '请在平地上正常步行20步,保持匀速')
|
||||
}
|
||||
|
||||
function calibrateBloodPressure() {
|
||||
startCalibrationProcess('血压计', '请使用标准血压计测量,确保袖带位置正确')
|
||||
}
|
||||
|
||||
function calibrateTemperature() {
|
||||
startCalibrationProcess('体温计', '请使用医用体温计对比校准,测量时间需2分钟')
|
||||
}
|
||||
|
||||
function stopCalibration() {
|
||||
if (calibrationTimer !== 0) {
|
||||
clearInterval(calibrationTimer)
|
||||
calibrationTimer = 0
|
||||
}
|
||||
calibrationProgress.value = 0
|
||||
}
|
||||
|
||||
function closeCalibration() {
|
||||
showCalibration.value = false
|
||||
stopCalibration()
|
||||
}
|
||||
|
||||
function startCalibration() {
|
||||
calibrationProgress.value = 1
|
||||
|
||||
// 模拟校准进度
|
||||
calibrationTimer = setInterval(() => {
|
||||
if (calibrationProgress.value < 100) {
|
||||
calibrationProgress.value += Math.random() * 10
|
||||
if (calibrationProgress.value > 100) {
|
||||
calibrationProgress.value = 100
|
||||
}
|
||||
} else {
|
||||
stopCalibration()
|
||||
uni.showToast({
|
||||
title: '校准完成',
|
||||
icon: 'success'
|
||||
})
|
||||
closeCalibration()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 数据导出
|
||||
function exportData(format : string) {
|
||||
uni.showLoading({
|
||||
title: '准备导出数据...'
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: `${format.toUpperCase()}文件已生成`,
|
||||
icon: 'success'
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// 重置功能
|
||||
function resetSettings() {
|
||||
uni.showModal({
|
||||
title: '确认重置',
|
||||
content: '确定要恢复所有设置到默认状态吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// 重置所有设置到默认值
|
||||
settings.value.realtime_enabled = true
|
||||
settings.value.auto_backup = true
|
||||
settings.value.anomaly_detection = true
|
||||
settings.value.ai_analysis = false
|
||||
|
||||
notifications.value.anomaly_alert = true
|
||||
notifications.value.daily_report = false
|
||||
notifications.value.device_offline = true
|
||||
notifications.value.report_time = '09:00'
|
||||
|
||||
dataManagement.value.retention_index = 2
|
||||
dataManagement.value.cloud_sync = true
|
||||
dataManagement.value.data_compression = false
|
||||
|
||||
privacy.value.data_encryption = true
|
||||
privacy.value.anonymous_stats = false
|
||||
privacy.value.location_data = false
|
||||
|
||||
initializeSensorFrequencies()
|
||||
initializeThresholds()
|
||||
|
||||
uni.showToast({
|
||||
title: '设置已重置',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function clearAllData() {
|
||||
uni.showModal({
|
||||
title: '危险操作',
|
||||
content: '确定要清除所有传感器数据吗?此操作不可恢复!',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.showToast({
|
||||
title: '数据清除完成',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
initializeSensorFrequencies()
|
||||
initializeThresholds()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-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;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
padding: 16rpx 24rpx;
|
||||
background-color: #409EFF;
|
||||
color: #ffffff;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20rpx;
|
||||
padding: 24rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.setting-item,
|
||||
.notification-item,
|
||||
.management-item,
|
||||
.privacy-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.setting-item:last-child,
|
||||
.notification-item:last-child,
|
||||
.management-item:last-child,
|
||||
.privacy-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.frequency-grid {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.frequency-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.frequency-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sensor-name {
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
width: 200rpx;
|
||||
}
|
||||
|
||||
.picker-view {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16rpx 20rpx;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8rpx;
|
||||
min-width: 200rpx;
|
||||
}
|
||||
|
||||
.picker-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.picker-arrow {
|
||||
color: #999999;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.threshold-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.threshold-item {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.threshold-label {
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.threshold-inputs {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
width: 48%;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.threshold-input {
|
||||
width: 100%;
|
||||
padding: 16rpx;
|
||||
border: 2rpx solid #e0e0e0;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.calibration-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.calibration-btn {
|
||||
padding: 20rpx;
|
||||
margin-bottom: 16rpx;
|
||||
background-color: #E6A23C;
|
||||
color: #ffffff;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.calibration-btn:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.export-options {
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
flex: 1;
|
||||
padding: 20rpx;
|
||||
margin: 0 8rpx;
|
||||
background-color: #67C23A;
|
||||
color: #ffffff;
|
||||
border-radius: 8rpx;
|
||||
font-size: 26rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.reset-options {
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
flex: 1;
|
||||
padding: 20rpx;
|
||||
margin-right: 16rpx;
|
||||
background-color: #909399;
|
||||
color: #ffffff;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
flex: 1;
|
||||
padding: 20rpx;
|
||||
background-color: #F56C6C;
|
||||
color: #ffffff;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.calibration-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: 85%;
|
||||
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 {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.calibration-instruction {
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.calibration-progress {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 16rpx;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 8rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: #409EFF;
|
||||
border-radius: 8rpx;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.calibration-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.start-btn,
|
||||
.stop-btn {
|
||||
width: 100%;
|
||||
padding: 24rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
background-color: #67C23A;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.stop-btn {
|
||||
background-color: #F56C6C;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
padding: 16rpx 20rpx;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
min-width: 200rpx;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
824
pages/sense/simulator.uvue
Normal file
824
pages/sense/simulator.uvue
Normal file
@@ -0,0 +1,824 @@
|
||||
<template>
|
||||
<scroll-view direction="vertical" class="simulator-container">
|
||||
<!-- 头部导航 -->
|
||||
<view class="header">
|
||||
<button class="back-btn" @click="goBack">
|
||||
<text class="back-icon">←</text>
|
||||
</button>
|
||||
<text class="title">传感器数据模拟器</text>
|
||||
<button class="clear-btn" @click="clearAllData">清除数据</button>
|
||||
</view>
|
||||
|
||||
<!-- 模拟控制面板 -->
|
||||
<view class="control-panel">
|
||||
<text class="panel-title">模拟控制</text>
|
||||
<view class="control-row">
|
||||
<button class="control-btn" :class="{ active: isSimulating }" @click="toggleSimulation">
|
||||
{{ isSimulating ? '停止模拟' : '开始模拟' }}
|
||||
</button>
|
||||
<text class="status-text">{{ simulationStatus }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 传感器配置 -->
|
||||
<view class="sensor-config">
|
||||
<text class="config-title">传感器配置</text>
|
||||
<view class="sensor-list">
|
||||
<view class="sensor-item" v-for="(sensor, index) in sensorConfigs" :key="index">
|
||||
<view class="sensor-header">
|
||||
<switch :checked="sensor.enabled"
|
||||
@change="onSensorToggle($event as UniSwitchChangeEvent, index)" />
|
||||
<text class="sensor-name">{{ sensor.name }}</text>
|
||||
</view>
|
||||
<view class="sensor-settings" v-if="sensor.enabled">
|
||||
<view class="setting-row">
|
||||
<text class="setting-label">频率</text>
|
||||
<button class="picker-button" @click="showFrequencyPicker(index)">
|
||||
<view class="picker-view">
|
||||
<text>{{ frequencyOptions[sensor.frequency_index] }}</text>
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
<view class="setting-row">
|
||||
<text class="setting-label">范围</text>
|
||||
<view class="range-inputs">
|
||||
<input class="range-input" type="number" v-model="sensor.min_value" placeholder="最小值" />
|
||||
<text class="range-separator">-</text>
|
||||
<input class="range-input" type="number" v-model="sensor.max_value" placeholder="最大值" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="setting-row">
|
||||
<text class="setting-label">变化趋势</text>
|
||||
<button class="picker-button" @click="showTrendPicker(index)">
|
||||
<view class="picker-view">
|
||||
<text>{{ trendOptions[sensor.trend_index] }}</text>
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<view class="stats-section">
|
||||
<text class="stats-title">生成统计</text>
|
||||
<view class="stats-grid">
|
||||
<view class="stats-item">
|
||||
<text class="stats-label">已生成数据</text>
|
||||
<text class="stats-value">{{ generatedCount }}</text>
|
||||
</view>
|
||||
<view class="stats-item">
|
||||
<text class="stats-label">运行时间</text>
|
||||
<text class="stats-value">{{ runningTime }}</text>
|
||||
</view>
|
||||
<view class="stats-item">
|
||||
<text class="stats-label">数据速率</text>
|
||||
<text class="stats-value">{{ dataRate }}/秒</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 实时数据预览 -->
|
||||
<view class="preview-section">
|
||||
<text class="preview-title">实时数据预览</text>
|
||||
<scroll-view class="preview-list" scroll-y>
|
||||
<view class="preview-item" v-for="(item, index) in recentData" :key="index">
|
||||
<view class="preview-header">
|
||||
<text class="preview-type">{{ item.type }}</text>
|
||||
<text class="preview-time">{{ formatTime(item.timestamp) }}</text>
|
||||
</view>
|
||||
<text class="preview-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 批量生成 -->
|
||||
<view class="batch-section">
|
||||
<text class="batch-title">批量生成</text>
|
||||
<view class="batch-form">
|
||||
<view class="form-row">
|
||||
<text class="form-label">数据量</text>
|
||||
<input class="form-input" type="number" v-model="batchCount" placeholder="请输入数据量" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">时间跨度</text>
|
||||
<view class="picker-view" @click="showTimeSpanPicker">
|
||||
<text>{{ timeSpanOptions[timeSpanIndex] }}</text>
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</view>
|
||||
<button class="batch-btn" @click="generateBatchData">生成批量数据</button>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import type { SensorConfig, RecentDataItem, SensorMeasurement } from './types.uts'
|
||||
|
||||
// 工具函数,放在 import 之后,所有变量和业务逻辑前
|
||||
function getFrequencySeconds(index : number) : number {
|
||||
// 频率选项:['1秒', '5秒', '10秒', '30秒', '1分钟', '5分钟']
|
||||
switch (index) {
|
||||
case 0: return 1;
|
||||
case 1: return 5;
|
||||
case 2: return 10;
|
||||
case 3: return 30;
|
||||
case 4: return 60;
|
||||
case 5: return 300;
|
||||
default: return 10;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function getUnit(sensorKey : string) : string {
|
||||
switch (sensorKey) {
|
||||
case 'heart_rate': return 'bpm'
|
||||
case 'steps': return '步'
|
||||
case 'spo2': return '%'
|
||||
case 'temp': return '°C'
|
||||
case 'bp': return 'mmHg'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
function formatPreviewValue(sensorKey : string, value : number) : string {
|
||||
switch (sensorKey) {
|
||||
case 'heart_rate': return `${Math.round(value)} bpm`
|
||||
case 'steps': return `${Math.round(value)} 步`
|
||||
case 'spo2': return `${Math.round(value)} %`
|
||||
case 'temp': return `${(Math.round(value * 10) / 10).toFixed(1)} 度`
|
||||
case 'bp': return `${Math.round(value)}/${Math.round(value * 0.7)} mmHg`
|
||||
default: return value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
function getTimeSpanHours(index : number) : number {
|
||||
// ['1小时', '1天', '1周', '1个月']
|
||||
switch (index) {
|
||||
case 0: return 1;
|
||||
case 1: return 24;
|
||||
case 2: return 24 * 7;
|
||||
case 3: return 24 * 30;
|
||||
default: return 24;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const isSimulating = ref<boolean>(false)
|
||||
const simulationStatus = ref<string>('已停止')
|
||||
const generatedCount = ref<number>(0)
|
||||
const runningTime = ref<string>('00:00:00')
|
||||
const dataRate = ref<number>(0)
|
||||
const recentData = ref<RecentDataItem[]>([])
|
||||
const batchCount = ref<string>('100')
|
||||
const timeSpanIndex = ref<number>(1)
|
||||
|
||||
// 传感器配置
|
||||
const sensorConfigs = ref<SensorConfig[]>([
|
||||
{
|
||||
key: 'heart_rate',
|
||||
name: '心率',
|
||||
enabled: true,
|
||||
frequency_index: 2,
|
||||
min_value: '60',
|
||||
max_value: '100',
|
||||
trend_index: 0
|
||||
},
|
||||
{
|
||||
key: 'steps',
|
||||
name: '步数',
|
||||
enabled: true,
|
||||
frequency_index: 3,
|
||||
min_value: '0',
|
||||
max_value: '50',
|
||||
trend_index: 1
|
||||
},
|
||||
{
|
||||
key: 'spo2',
|
||||
name: '血氧',
|
||||
enabled: false,
|
||||
frequency_index: 4,
|
||||
min_value: '95',
|
||||
max_value: '100',
|
||||
trend_index: 0
|
||||
},
|
||||
{
|
||||
key: 'temp',
|
||||
name: '体温',
|
||||
enabled: false,
|
||||
frequency_index: 5,
|
||||
min_value: '36.0',
|
||||
max_value: '37.5',
|
||||
trend_index: 0
|
||||
},
|
||||
{
|
||||
key: 'bp',
|
||||
name: '血压',
|
||||
enabled: false,
|
||||
frequency_index: 5,
|
||||
min_value: '90',
|
||||
max_value: '140',
|
||||
trend_index: 0
|
||||
}
|
||||
])
|
||||
|
||||
// 选项数据
|
||||
const frequencyOptions = ['1秒', '5秒', '10秒', '30秒', '1分钟', '5分钟']
|
||||
const trendOptions = ['随机', '上升', '下降', '波动']
|
||||
const timeSpanOptions = ['1小时', '1天', '1周', '1个月']
|
||||
|
||||
const userId = 'eed3824b-bba1-4309-8048-19d17367c084'
|
||||
const deviceId = '12345678-1234-5678-9abc-123456789012'
|
||||
let simulationTimer : number | null = null
|
||||
let startTime : number = 0
|
||||
function generateValueWithTrend(min : number, max : number, trend : number) : number {
|
||||
let value : number
|
||||
if (trend === 0) { // 随机
|
||||
value = min + Math.random() * (max - min)
|
||||
} else if (trend === 1) { // 上升
|
||||
const progress = (generatedCount.value % 100) / 100
|
||||
value = min + progress * (max - min) + Math.random() * (max - min) * 0.1
|
||||
} else if (trend === 2) { // 下降
|
||||
const progress = 1 - (generatedCount.value % 100) / 100
|
||||
value = min + progress * (max - min) + Math.random() * (max - min) * 0.1
|
||||
} else { // 波动
|
||||
const wave = Math.sin((generatedCount.value % 100) / 100 * Math.PI * 2)
|
||||
value = (min + max) / 2 + wave * (max - min) / 4 + Math.random() * (max - min) * 0.1
|
||||
}
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
|
||||
async function generateBatchSensorData(sensor : SensorConfig, timestamp : Date) {
|
||||
if (supa === null) return
|
||||
|
||||
const sensorKey = sensor.key
|
||||
const minValue = parseFloat(sensor.min_value)
|
||||
const maxValue = parseFloat(sensor.max_value)
|
||||
|
||||
const value = minValue + Math.random() * (maxValue - minValue)
|
||||
const rawData = new UTSJSONObject()
|
||||
|
||||
// 根据传感器类型构建原始数据
|
||||
if (sensorKey === 'heart_rate') {
|
||||
rawData.set('bpm', Math.round(value))
|
||||
} else if (sensorKey === 'steps') {
|
||||
rawData.set('count', Math.round(value))
|
||||
} else if (sensorKey === 'spo2') {
|
||||
rawData.set('spo2', Math.round(value))
|
||||
} else if (sensorKey === 'temp') {
|
||||
rawData.set('temp', Math.round(value * 10) / 10)
|
||||
} else if (sensorKey === 'bp') {
|
||||
rawData.set('systolic', Math.round(value))
|
||||
rawData.set('diastolic', Math.round(value * 0.7))
|
||||
}
|
||||
|
||||
const measurementData = new UTSJSONObject()
|
||||
measurementData.set('device_id', deviceId)
|
||||
measurementData.set('user_id', userId)
|
||||
measurementData.set('measurement_type', sensorKey)
|
||||
measurementData.set('measured_at', timestamp.toISOString())
|
||||
measurementData.set('unit', getUnit(sensorKey))
|
||||
measurementData.set('raw_data', rawData)
|
||||
|
||||
await supa.from('ss_sensor_measurements')
|
||||
.insert(measurementData)
|
||||
.execute()
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function generateBatchData() {
|
||||
if (supa === null) return
|
||||
|
||||
const count = parseInt(batchCount.value) ?? 100
|
||||
const timeSpan = getTimeSpanHours(timeSpanIndex.value)
|
||||
|
||||
uni.showLoading({
|
||||
title: '生成批量数据中...'
|
||||
})
|
||||
|
||||
try {
|
||||
const endTime = new Date()
|
||||
const startTime = new Date(endTime.getTime() - timeSpan * 3600000)
|
||||
const interval = (timeSpan * 3600000) / count
|
||||
|
||||
for (let i : Int = 0; i < count; i++) {
|
||||
const timestamp = new Date(startTime.getTime() + i * interval)
|
||||
|
||||
const enabledSensors = sensorConfigs.value.filter(sensor => sensor.enabled)
|
||||
for (let j : Int = 0; j < enabledSensors.length; j++) {
|
||||
const sensor = enabledSensors[j]
|
||||
await generateBatchSensorData(sensor, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: '批量数据生成完成',
|
||||
icon: 'success'
|
||||
})
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: '生成失败',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAllData() {
|
||||
uni.showModal({
|
||||
title: '确认清除',
|
||||
content: '确定要清除所有模拟数据吗?此操作不可恢复!',
|
||||
success: function (res) {
|
||||
if (res.confirm && supa !== null) {
|
||||
(async () => {
|
||||
try {
|
||||
await supa.from('ss_sensor_measurements')
|
||||
.delete()
|
||||
.eq('device_id', deviceId)
|
||||
.execute()
|
||||
|
||||
generatedCount.value = 0
|
||||
recentData.value = []
|
||||
|
||||
uni.showToast({
|
||||
title: '数据清除完成',
|
||||
icon: 'success'
|
||||
})
|
||||
} catch (e) {
|
||||
uni.showToast({
|
||||
title: '清除失败',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
// 更新运行时间
|
||||
const elapsed = Date.now() - startTime
|
||||
const hours = Math.floor(elapsed / 3600000)
|
||||
const minutes = Math.floor((elapsed % 3600000) / 60000)
|
||||
const seconds = Math.floor((elapsed % 60000) / 1000)
|
||||
runningTime.value = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
|
||||
// 更新数据速率
|
||||
if (elapsed > 0) {
|
||||
dataRate.value = Math.round((generatedCount.value / elapsed) * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 事件处理
|
||||
function onSensorToggle(e : UniSwitchChangeEvent, index : number) {
|
||||
if (index >= 0 && index < sensorConfigs.value.length) {
|
||||
sensorConfigs.value[index].enabled = e.detail.value
|
||||
}
|
||||
}
|
||||
|
||||
function onFrequencyChange(e : number, index : number) {
|
||||
if (index >= 0 && index < sensorConfigs.value.length) {
|
||||
sensorConfigs.value[index].frequency_index = e
|
||||
}
|
||||
}
|
||||
|
||||
function onTrendChange(e : number, index : number) {
|
||||
if (index >= 0 && index < sensorConfigs.value.length) {
|
||||
sensorConfigs.value[index].trend_index = e
|
||||
}
|
||||
}
|
||||
|
||||
// ActionSheet handlers
|
||||
function showFrequencyPicker(index : number) {
|
||||
uni.showActionSheet({
|
||||
itemList: frequencyOptions,
|
||||
success: (res) => {
|
||||
if (res.tapIndex >= 0) {
|
||||
onFrequencyChange(res.tapIndex, index)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function showTrendPicker(index : number) {
|
||||
uni.showActionSheet({
|
||||
itemList: trendOptions, // FIXED: removed .value
|
||||
success: (res) => {
|
||||
if (res.tapIndex >= 0) {
|
||||
onTrendChange(res.tapIndex, index)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function showTimeSpanPicker() {
|
||||
uni.showActionSheet({
|
||||
itemList: timeSpanOptions,
|
||||
success: (res) => {
|
||||
if (res.tapIndex >= 0) {
|
||||
timeSpanIndex.value = res.tapIndex
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function generateSensorData(sensor : SensorConfig) {
|
||||
if (supa === null) return
|
||||
|
||||
const sensorKey = sensor.key
|
||||
const minValue = parseFloat(sensor.min_value)
|
||||
const maxValue = parseFloat(sensor.max_value)
|
||||
const trend = sensor.trend_index
|
||||
|
||||
let value = generateValueWithTrend(minValue, maxValue, trend)
|
||||
const rawData = new UTSJSONObject()
|
||||
|
||||
// 根据传感器类型构建原始数据
|
||||
if (sensorKey === 'heart_rate') {
|
||||
rawData.set('bpm', Math.round(value))
|
||||
rawData.set('rr_interval', Math.round(60000 / value))
|
||||
} else if (sensorKey === 'steps') {
|
||||
rawData.set('count', Math.round(value))
|
||||
rawData.set('distance', Math.round(value * 0.7))
|
||||
} else if (sensorKey === 'spo2') {
|
||||
rawData.set('spo2', Math.round(value))
|
||||
rawData.set('pi', Math.round(Math.random() * 10) / 10)
|
||||
} else if (sensorKey === 'temp') {
|
||||
rawData.set('temp', Math.round(value * 10) / 10)
|
||||
} else if (sensorKey === 'bp') {
|
||||
rawData.set('systolic', Math.round(value))
|
||||
rawData.set('diastolic', Math.round(value * 0.7))
|
||||
}
|
||||
|
||||
// 构建测量数据
|
||||
const measurementData = new UTSJSONObject()
|
||||
measurementData.set('device_id', deviceId)
|
||||
measurementData.set('user_id', userId)
|
||||
measurementData.set('measurement_type', sensorKey)
|
||||
measurementData.set('measured_at', new Date().toISOString())
|
||||
measurementData.set('unit', getUnit(sensorKey))
|
||||
measurementData.set('raw_data', rawData)
|
||||
|
||||
try {
|
||||
await supa.from('ss_sensor_measurements')
|
||||
.insert(measurementData)
|
||||
.execute()
|
||||
|
||||
// 添加到预览列表
|
||||
const previewItem : RecentDataItem = {
|
||||
type: sensor.name,
|
||||
value: formatPreviewValue(sensorKey, value),
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
recentData.value.unshift(previewItem)
|
||||
if (recentData.value.length > 20) {
|
||||
recentData.value = recentData.value.slice(0, 20)
|
||||
}
|
||||
|
||||
generatedCount.value++
|
||||
} catch (e) {
|
||||
console.log('生成数据失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function generateRealtimeData() {
|
||||
const enabledSensors = sensorConfigs.value.filter(sensor => sensor.enabled)
|
||||
|
||||
for (let i : Int = 0; i < enabledSensors.length; i++) {
|
||||
const sensor = enabledSensors[i]
|
||||
const frequency = getFrequencySeconds(sensor.frequency_index)
|
||||
|
||||
// 根据频率决定是否生成数据
|
||||
if (generatedCount.value % frequency === 0) {
|
||||
await generateSensorData(sensor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startSimulation() {
|
||||
isSimulating.value = true
|
||||
simulationStatus.value = '运行中'
|
||||
startTime = Date.now()
|
||||
generatedCount.value = 0
|
||||
|
||||
// 开始模拟定时器
|
||||
simulationTimer = setInterval(() => {
|
||||
generateRealtimeData()
|
||||
updateStats()
|
||||
}, 1000) // 每秒生成一次数据
|
||||
}
|
||||
|
||||
function stopSimulation() {
|
||||
isSimulating.value = false
|
||||
simulationStatus.value = '已停止'
|
||||
|
||||
if (simulationTimer !== null) {
|
||||
clearInterval(simulationTimer as number) // FIXED: type assertion
|
||||
simulationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function formatTime(timestamp : number) : string {
|
||||
const date = new Date(timestamp)
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
const h = date.getHours().toString().padStart(2, '0')
|
||||
const min = date.getMinutes().toString().padStart(2, '0')
|
||||
const s = date.getSeconds().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${min}:${s}`
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
onMounted(() => {
|
||||
// supa 已全局初始化,无需手动实例化
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSimulation()
|
||||
})
|
||||
|
||||
function toggleSimulation() {
|
||||
if (isSimulating.value) {
|
||||
stopSimulation()
|
||||
} else {
|
||||
startSimulation()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.simulator-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;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
padding: 16rpx 24rpx;
|
||||
background-color: #F56C6C;
|
||||
color: #ffffff;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.control-panel,
|
||||
.sensor-config,
|
||||
.stats-section,
|
||||
.preview-section,
|
||||
.batch-section {
|
||||
margin-bottom: 20rpx;
|
||||
padding: 24rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.panel-title,
|
||||
.config-title,
|
||||
.stats-title,
|
||||
.preview-title,
|
||||
.batch-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 20rpx 40rpx;
|
||||
background-color: #409EFF;
|
||||
color: #ffffff;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
background-color: #F56C6C;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.sensor-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sensor-item {
|
||||
margin-bottom: 24rpx;
|
||||
padding: 20rpx;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.sensor-header {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.sensor-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.sensor-settings {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
width: 120rpx;
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.picker-view {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16rpx 20rpx;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8rpx;
|
||||
min-width: 160rpx;
|
||||
}
|
||||
|
||||
.picker-button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.picker-arrow {
|
||||
color: #999999;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.range-inputs {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.range-input {
|
||||
flex: 1;
|
||||
padding: 16rpx;
|
||||
border: 2rpx solid #e0e0e0;
|
||||
border-radius: 8rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.range-separator {
|
||||
margin: 0 16rpx;
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.preview-list {
|
||||
max-height: 400rpx;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.preview-type {
|
||||
font-size: 26rpx;
|
||||
color: #333333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.preview-time {
|
||||
font-size: 22rpx;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.preview-value {
|
||||
font-size: 28rpx;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.batch-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
width: 150rpx;
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
padding: 20rpx;
|
||||
border: 2rpx solid #e0e0e0;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.batch-btn {
|
||||
padding: 24rpx;
|
||||
background-color: #67C23A;
|
||||
color: #ffffff;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
</style>
|
||||
301
pages/sense/types.uts
Normal file
301
pages/sense/types.uts
Normal file
@@ -0,0 +1,301 @@
|
||||
// 传感器数据类型定义
|
||||
export type SensorMeasurement = {
|
||||
id: string;
|
||||
device_id: string;
|
||||
user_id: string;
|
||||
measurement_type: string;
|
||||
measured_at: string;
|
||||
unit: string;
|
||||
raw_data: UTSJSONObject;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type SensorAnalysisResult = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
analysis_type: string;
|
||||
result: UTSJSONObject;
|
||||
summary: string;
|
||||
recommendations: Array<string>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type DeviceInfo = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
device_type: string;
|
||||
device_name: string;
|
||||
device_mac: string;
|
||||
bind_time: string;
|
||||
status: string;
|
||||
extra: UTSJSONObject;
|
||||
}
|
||||
|
||||
export type ChartDataPoint = {
|
||||
time: string;
|
||||
value: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// 扩展的传感器数据类型
|
||||
export type HeartRateData = {
|
||||
bpm: number;
|
||||
rr_interval?: number;
|
||||
hrv?: number;
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export type StepsData = {
|
||||
count: number;
|
||||
distance?: number;
|
||||
calories?: number;
|
||||
cadence?: number;
|
||||
}
|
||||
|
||||
export type SpO2Data = {
|
||||
spo2: number;
|
||||
pi?: number;
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export type BloodPressureData = {
|
||||
systolic: number;
|
||||
diastolic: number;
|
||||
pulse_pressure?: number;
|
||||
map?: number;
|
||||
}
|
||||
|
||||
export type TemperatureData = {
|
||||
temp: number;
|
||||
ambient_temp?: number;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export type StrideData = {
|
||||
stride_length: number;
|
||||
step_frequency: number;
|
||||
ground_contact_time?: number;
|
||||
flight_time?: number;
|
||||
}
|
||||
|
||||
export type LocationData = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
altitude?: number;
|
||||
accuracy?: number;
|
||||
speed?: number;
|
||||
heading?: number;
|
||||
}
|
||||
|
||||
export type ECGData = {
|
||||
raw_signal: Array<number>;
|
||||
heart_rate?: number;
|
||||
rhythm_type?: string;
|
||||
qrs_duration?: number;
|
||||
pr_interval?: number;
|
||||
qt_interval?: number;
|
||||
}
|
||||
|
||||
// 设备配置类型
|
||||
export type DeviceConfig = {
|
||||
sample_rate: string;
|
||||
upload_interval: string;
|
||||
auto_sync: boolean;
|
||||
battery_optimization: boolean;
|
||||
data_encryption: boolean;
|
||||
}
|
||||
|
||||
// 数据分析配置
|
||||
export type AnalysisConfig = {
|
||||
analysis_types: Array<string>;
|
||||
time_range: string;
|
||||
alert_thresholds: UTSJSONObject;
|
||||
ai_enabled: boolean;
|
||||
}
|
||||
|
||||
// 实时数据订阅配置
|
||||
export type RealtimeConfig = {
|
||||
channels: Array<string>;
|
||||
filters: UTSJSONObject;
|
||||
max_frequency: number;
|
||||
}
|
||||
|
||||
// 传感器类型枚举
|
||||
export const SENSOR_TYPES = {
|
||||
HEART_RATE: 'heart_rate',
|
||||
STEPS: 'steps',
|
||||
SPO2: 'spo2',
|
||||
BLOOD_PRESSURE: 'bp',
|
||||
TEMPERATURE: 'temp',
|
||||
STRIDE: 'stride',
|
||||
LOCATION: 'location',
|
||||
ECG: 'ecg',
|
||||
RESPIRATORY_RATE: 'respiratory_rate',
|
||||
BLOOD_GLUCOSE: 'blood_glucose',
|
||||
SLEEP: 'sleep',
|
||||
FALL_DETECTION: 'fall_detection'
|
||||
} as const
|
||||
|
||||
// 设备类型枚举
|
||||
export const DEVICE_TYPES = {
|
||||
SMARTWATCH: 'smartwatch',
|
||||
FITNESS_BAND: 'fitness_band',
|
||||
HEART_MONITOR: 'heart_monitor',
|
||||
BLOOD_PRESSURE_MONITOR: 'blood_pressure',
|
||||
THERMOMETER: 'thermometer',
|
||||
PEDOMETER: 'pedometer',
|
||||
GPS_TRACKER: 'gps_tracker',
|
||||
ECG_MONITOR: 'ecg_monitor'
|
||||
} as const
|
||||
|
||||
// 健康评分数据类型
|
||||
export type HealthScoreBreakdown = {
|
||||
category: string;
|
||||
score: number;
|
||||
percentage: number;
|
||||
trend: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// 健康异常数据类型
|
||||
export type HealthAnomaly = {
|
||||
id: string;
|
||||
type: string;
|
||||
severity: string;
|
||||
description: string;
|
||||
detected_at: string;
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
// AI分析报告类型
|
||||
export type AIAnalysisReport = {
|
||||
health_assessment: string;
|
||||
trend_analysis: string;
|
||||
recommendations: Array<string>;
|
||||
risk_factors: Array<string>;
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
// 趋势对比数据类型
|
||||
export type TrendComparison = {
|
||||
current_period: number;
|
||||
previous_period: number;
|
||||
change_percentage: number;
|
||||
change_direction: string;
|
||||
metric_name: string;
|
||||
time_range: string;
|
||||
}
|
||||
|
||||
// Settings related types
|
||||
export type UserSettings = {
|
||||
realtime_enabled: boolean
|
||||
auto_backup: boolean
|
||||
anomaly_detection: boolean
|
||||
ai_analysis: boolean
|
||||
}
|
||||
|
||||
export type NotificationSettings = {
|
||||
anomaly_alert: boolean
|
||||
daily_report: boolean
|
||||
device_offline: boolean
|
||||
report_time: string
|
||||
}
|
||||
|
||||
export type DataManagementSettings = {
|
||||
retention_index: number
|
||||
cloud_sync: boolean
|
||||
data_compression: boolean
|
||||
}
|
||||
|
||||
export type PrivacySettings = {
|
||||
data_encryption: boolean
|
||||
anonymous_stats: boolean
|
||||
location_data: boolean
|
||||
}
|
||||
|
||||
export type SensorFrequency = {
|
||||
name: string
|
||||
key: string
|
||||
frequency_index: number
|
||||
}
|
||||
|
||||
export type SensorThreshold = {
|
||||
name: string
|
||||
key: string
|
||||
min_value: string
|
||||
max_value: string
|
||||
}
|
||||
|
||||
export type UserSettingsRecord = {
|
||||
id: string
|
||||
user_id: string
|
||||
setting_category: string
|
||||
setting_key: string
|
||||
setting_value: UTSJSONObject
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Simulator related types
|
||||
export type SensorConfig = {
|
||||
key: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
frequency_index: number
|
||||
min_value: string
|
||||
max_value: string
|
||||
trend_index: number
|
||||
}
|
||||
|
||||
export type RecentDataItem = {
|
||||
type: string
|
||||
value: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// Settings configuration types
|
||||
export type SensorConfigItem = {
|
||||
name: string
|
||||
key: string
|
||||
frequency_index: number
|
||||
}
|
||||
|
||||
export type ThresholdConfigItem = {
|
||||
name: string
|
||||
key: string
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
import type { HealthData } from '@/uni_modules/ak-sbsrv/utssdk/interface.uts'
|
||||
|
||||
// BLE健康监控相关类型定义
|
||||
export type NotifyLogItem = {
|
||||
ts: number
|
||||
pkt: HealthData
|
||||
hex?: string
|
||||
b64?: string
|
||||
}
|
||||
|
||||
export type RealtimeMetric = {
|
||||
type: string
|
||||
label: string
|
||||
icon: string
|
||||
value: string
|
||||
unit: string
|
||||
high?: string
|
||||
low?: string
|
||||
}
|
||||
|
||||
export type ChartBuffer = {
|
||||
type: string
|
||||
data: Array<number>
|
||||
labels: Array<string>
|
||||
}
|
||||
|
||||
export type HealthSample = {
|
||||
heartRate: number
|
||||
spo2: number
|
||||
steps: number
|
||||
speed: number
|
||||
timestamp: Date
|
||||
}
|
||||
Reference in New Issue
Block a user