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

832 lines
20 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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;
}
</style>