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

824 lines
20 KiB
Plaintext

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