Initial commit of akmon project

This commit is contained in:
2026-01-20 08:04:15 +08:00
commit 77a2bab985
1309 changed files with 343305 additions and 0 deletions

View 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) {
// 设备已自动添加到storeUI自动更新
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
View 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
View 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>

View 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
View 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

File diff suppressed because it is too large Load Diff

951
pages/sense/index.uvue Normal file
View 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>

View 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
View 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>

View 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
View 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
View 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
View 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
}