Files
akmon/pages/sport/student/device-management.uvue
2026-01-20 08:04:15 +08:00

1037 lines
23 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>
<scroll-view class="device-management-page">
<!-- Header -->
<view class="header">
<view class="header-left">
<button @click="goBack" class="back-btn">
<simple-icon type="arrow-left" :size="16" color="#FFFFFF" />
<text>返回</text>
</button>
<text class="title">设备管理</text>
</view>
<view class="header-actions">
<button @click="scanDevices" class="scan-btn">
<simple-icon type="search" :size="16" color="#FFFFFF" />
<text>扫描</text>
</button>
</view>
</view>
<!-- Loading State -->
<view v-if="loading" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
<!-- Content -->
<scroll-view v-else class="content" scroll-y="true" :style="{ height: contentHeight + 'px' }">
<!-- Device Statistics -->
<view class="stats-section">
<view class="stat-card">
<text class="stat-number">{{ connectedDevices }}</text>
<text class="stat-label">已连接</text>
</view>
<view class="stat-card">
<text class="stat-number">{{ totalDevices }}</text>
<text class="stat-label">设备总数</text>
</view>
<view class="stat-card">
<text class="stat-number">{{ onlineDevices }}</text>
<text class="stat-label">在线设备</text>
</view>
</view>
<!-- Add Device Section -->
<view class="add-device-section">
<text class="section-title">添加新设备</text>
<view class="device-types">
<view class="device-type-item" v-for="type in deviceTypes" :key="type.type"
@click="addDevice(type)">
<text class="device-type-icon">{{ type.icon }}</text>
<text class="device-type-name">{{ type.name }}</text>
<text class="device-type-desc">{{ type.description }}</text>
</view>
</view>
</view>
<!-- My Devices Section -->
<view class="devices-section">
<text class="section-title">我的设备</text>
<view v-if="userDevices.length === 0" class="empty-state">
<text class="empty-icon"></text>
<text class="empty-title">还没有绑定设备</text>
<text class="empty-subtitle">添加您的智能设备,开始记录运动数据</text>
<button @click="scanDevices" class="scan-devices-btn">扫描设备</button>
</view>
<view v-else class="devices-list">
<view class="device-item" v-for="device in userDevices" :key="device.id"
@click="deviceDetail(device)">
<view class="device-icon">
<text class="device-emoji">{{ getDeviceIcon(device.device_type as string) }}</text>
<view class="device-status" :class="getDeviceStatusClass(device.status as string)">
<text class="status-dot"></text>
</view>
</view>
<view class="device-info">
<text class="device-name">{{ getDeviceName(device) }}</text>
<text class="device-type">{{ getDeviceTypeName(device.device_type as string) }}</text>
<text class="device-status-text">{{ getDeviceStatusText(device) }}</text>
</view>
<view class="device-actions">
<view class="device-battery" v-if="getDeviceBattery(device) > 0">
<text class="battery-icon"></text>
<text class="battery-level">{{ getDeviceBattery(device) }}%</text>
</view>
<button class="action-btn" @click.stop="toggleDevice(device)">
<text class="action-text">{{ device.status === 'active' ? '断开' : '连接' }}</text>
</button>
</view>
</view>
</view>
</view>
<!-- Scanning Animation -->
<view v-if="isScanning" class="scanning-overlay">
<view class="scanning-content">
<view class="scanning-animation">
<text class="scanning-icon"></text>
<view class="scanning-rings">
<view class="ring ring-1"></view>
<view class="ring ring-2"></view>
<view class="ring ring-3"></view>
</view>
</view>
<text class="scanning-text">正在扫描设备...</text>
<text class="scanning-hint">请确保设备已开启并处于配对模式</text>
<button @click="stopScanning" class="stop-scan-btn">停止扫描</button>
</view>
</view>
</scroll-view>
<!-- Add Device Modal -->
<view v-if="showAddModal" class="modal-overlay" @click="closeAddModal">
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">添加{{ selectedDeviceType?.name }}</text>
<text class="modal-close" @click="closeAddModal">×</text>
</view>
<view class="modal-body">
<view class="form-group">
<text class="form-label">设备名称</text>
<input class="form-input" v-model="deviceForm.device_name" placeholder="为您的设备起个名字" />
</view>
<view class="form-group">
<text class="form-label">设备MAC地址</text>
<input class="form-input" v-model="deviceForm.device_mac" placeholder="00:00:00:00:00:00" />
</view>
<view class="form-group">
<text class="form-label">设备描述</text>
<textarea class="form-textarea" v-model="deviceForm.description" placeholder="描述设备特征或用途..." />
</view>
</view>
<view class="modal-footer">
<button class="modal-button cancel" @click="closeAddModal">取消</button>
<button class="modal-button confirm" @click="saveDevice">添加设备</button>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed, onUnmounted } from 'vue'
import { onLoad, onResize } from '@dcloudio/uni-app'
import supaClient from '@/components/supadb/aksupainstance.uts'
import { getCurrentUserId } from '@/utils/store.uts'
const userId = ref('')
// Responsive state - using onResize for dynamic updates
const screenWidth = ref<number>(uni.getSystemInfoSync().windowWidth)
// Computed properties for responsive design
const isLargeScreen = computed(() : boolean => {
return screenWidth.value >= 768
})
const userDevices = ref<UTSJSONObject[]>([])
const loading = ref<boolean>(true)
const isScanning = ref<boolean>(false)
const showAddModal = ref<boolean>(false)
const selectedDeviceType = ref<UTSJSONObject | null>(null)
const connectedDevices = ref<number>(0)
const totalDevices = ref<number>(0)
const onlineDevices = ref<number>(0)
const deviceForm = ref<UTSJSONObject>({
device_name: '',
device_mac: '',
device_type: '',
description: ''
})
const deviceTypes = ref<UTSJSONObject[]>([
{
type: 'smart_band',
name: '智能手环',
icon: '⌚',
description: '监测心率、步数、睡眠等数据'
},
{
type: 'smart_watch',
name: '智能手表',
icon: '⌚',
description: '功能更全面的智能穿戴设备'
},
{
type: 'smart_ring',
name: '智能指环',
icon: '',
description: '轻巧便携的健康监测设备'
},
{
type: 'heart_rate_monitor',
name: '心率监测器',
icon: '❤️',
description: '专业的心率监测设备'
},
{
type: 'fitness_tracker',
name: '健身追踪器',
icon: '',
description: '专注运动数据的追踪设备'
},
{
type: 'smart_scale',
name: '智能体重秤',
icon: '⚖️',
description: '测量体重、体脂等身体数据'
}
])
const contentHeight = computed(() => {
const systemInfo = uni.getSystemInfoSync()
return systemInfo.windowHeight - 120 // 减去header高度
})
const getMockDevices = () : UTSJSONObject[] => {
return [
{
id: '1',
device_type: 'smart_band',
device_name: '我的小米手环',
device_mac: 'AA:BB:CC:DD:EE:FF',
status: 'active',
bind_time: new Date().toISOString(),
extra: {
battery: 85,
firmware_version: '1.2.3',
last_sync: new Date().toISOString()
}
},
{
id: '2',
device_type: 'smart_watch',
device_name: 'Apple Watch',
device_mac: '11:22:33:44:55:66',
status: 'offline',
bind_time: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
extra: {
battery: 45,
firmware_version: '2.1.0',
last_sync: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString()
}
}
]
}
const calculateStats = () => {
totalDevices.value = userDevices.value.length
connectedDevices.value = userDevices.value.filter(device =>
device instanceof UTSJSONObject && device.getString('status') === 'active'
).length
onlineDevices.value = userDevices.value.filter(device => {
if (!(device instanceof UTSJSONObject)) return false
const extra = device.get('extra')
let lastSync = ''
if (extra instanceof UTSJSONObject) {
lastSync = extra.getString('last_sync') ?? ''
}
if (lastSync === '') return false
const lastSyncTime = new Date(lastSync).getTime()
const now = new Date().getTime()
return now - lastSyncTime < 30 * 60 * 1000 // 30分钟内同步过算在线
}).length
}
const loadUserDevices = async () => {
try {
loading.value = true
const result = await supaClient
.from('ak_devices')
.select('*', { count: 'exact' })
.eq('user_id', userId.value)
.order('bind_time', { ascending: false })
.execute()
if (result.status == 200) {
userDevices.value = result.data as UTSJSONObject[]
calculateStats()
} else {
console.error('加载设备列表失败:', result.error)
// 使用模拟数据
userDevices.value = getMockDevices()
calculateStats()
}
} catch (error) {
console.error('加载设备列表异常:', error)
userDevices.value = getMockDevices()
calculateStats()
} finally {
loading.value = false
}
}
const getDeviceTypeName = (deviceType : string) : string => {
const typeMap = new UTSJSONObject()
typeMap.set('smart_band', '智能手环')
typeMap.set('smart_watch', '智能手表')
typeMap.set('smart_ring', '智能指环')
typeMap.set('heart_rate_monitor', '心率监测器')
typeMap.set('fitness_tracker', '健身追踪器')
typeMap.set('smart_scale', '智能体重秤')
return typeMap.getString(deviceType) ?? '未知设备'
}
// 设备相关获取函数
const getDeviceName = (device : UTSJSONObject) : string => {
if (!(device instanceof UTSJSONObject)) return ''
const name = device.getString('device_name') ?? ''
return name !== '' ? name : `未命名${getDeviceTypeName(device.getString('device_type') ?? '')}`
}
const getDeviceIcon = (deviceType : string) : string => {
const iconMap = new UTSJSONObject()
iconMap.set('smart_band', '⌚')
iconMap.set('smart_watch', '⌚')
iconMap.set('smart_ring', '')
iconMap.set('heart_rate_monitor', '❤️')
iconMap.set('fitness_tracker', '')
iconMap.set('smart_scale', '⚖️')
return iconMap.getString(deviceType) ?? ''
}
const getDeviceStatusClass = (status : string) : string => {
return `status-${status}`
}
const getDeviceStatusText = (device : UTSJSONObject) : string => {
if (!(device instanceof UTSJSONObject)) return ''
const status = device.getString('status') ?? 'offline'
let lastSync = ''
const extra = device.get('extra')
if (extra instanceof UTSJSONObject) {
lastSync = extra.getString('last_sync') ?? ''
}
if (status === 'active') {
if (typeof lastSync === 'string' && lastSync.length > 0) {
const lastSyncTime = new Date(lastSync).getTime()
const now = new Date().getTime()
const diffMinutes = Math.floor((now - lastSyncTime) / (60 * 1000))
if (diffMinutes < 5) {
return '在线'
} else if (diffMinutes < 30) {
return `${diffMinutes}分钟前同步`
} else {
return '已连接但离线'
}
}
return '已连接'
}
return '离线'
}
// 关闭添加设备弹窗
const closeAddModal = () => {
showAddModal.value = false
selectedDeviceType.value = null
deviceForm.value = new UTSJSONObject()
deviceForm.value.set('device_name', '')
deviceForm.value.set('device_mac', '')
deviceForm.value.set('device_type', '')
deviceForm.value.set('description', '')
}
const getDeviceBattery = (device : UTSJSONObject) : number => {
if (!(device instanceof UTSJSONObject)) return 0
const extra = device.get('extra')
if (extra instanceof UTSJSONObject) {
return extra.getNumber('battery') ?? 0
}
return 0
}
// 扫描设备
const scanDevices = () => {
isScanning.value = true
// 模拟扫描过程
setTimeout(() => {
isScanning.value = false
uni.showToast({
title: '扫描完成',
icon: 'success'
})
}, 3000)
}
const stopScanning = () => {
isScanning.value = false
}
// 添加设备
const addDevice = (deviceType : UTSJSONObject) => {
selectedDeviceType.value = deviceType
deviceForm.value = new UTSJSONObject()
deviceForm.value.set('device_name', '')
deviceForm.value.set('device_mac', '')
deviceForm.value.set('device_type', deviceType.getString('type') ?? '')
deviceForm.value.set('description', '')
showAddModal.value = true
}
// 保存设备
const saveDevice = async () => {
try {
if (!(deviceForm.value instanceof UTSJSONObject)) return
if (deviceForm.value.getString('device_name') === '' || deviceForm.value.getString('device_type') === '') {
uni.showToast({
title: '请填写设备信息',
icon: 'none'
})
return
}
const deviceData = new UTSJSONObject()
deviceData.set('user_id', userId.value)
deviceData.set('device_type', deviceForm.value.getString('device_type') ?? '')
deviceData.set('device_name', deviceForm.value.getString('device_name') ?? '')
deviceData.set('device_mac', deviceForm.value.getString('device_mac') ?? '')
deviceData.set('status', 'active')
const extra = new UTSJSONObject()
extra.set('description', deviceForm.value.getString('description') ?? '')
extra.set('battery', 100)
extra.set('firmware_version', '1.0.0')
extra.set('last_sync', new Date().toISOString())
deviceData.set('extra', extra)
const result = await supaClient
.from('ak_devices')
.insert(deviceData)
.execute()
if (result.status === 201 || result.status === 200) {
uni.showToast({
title: '设备添加成功',
icon: 'success'
})
closeAddModal()
loadUserDevices()
} else {
uni.showToast({
title: '添加失败',
icon: 'none'
})
}
} catch (error) {
console.error('添加设备失败:', error)
uni.showToast({
title: '添加失败',
icon: 'none'
})
}
}
// 切换设备状态
const toggleDevice = async (device : UTSJSONObject) => {
if (!(device instanceof UTSJSONObject)) return
const deviceId = device.getString('id') ?? ''
const currentStatus = device.getString('status') ?? ''
const newStatus = currentStatus === 'active' ? 'offline' : 'active'
try {
const result = await supaClient
.from('ak_devices')
.update({ status: newStatus })
.eq('id', deviceId)
.execute()
if (result.status === 201 || result.status === 200) {
uni.showToast({
title: newStatus === 'active' ? '设备已连接' : '设备已断开',
icon: 'success'
})
loadUserDevices()
} else {
uni.showToast({
title: '操作失败',
icon: 'none'
})
}
} catch (error) {
console.error('切换设备状态失败:', error)
uni.showToast({
title: '操作失败',
icon: 'none'
})
}
}
// 设备详情
const deviceDetail = (device : UTSJSONObject) => {
if (!(device instanceof UTSJSONObject)) return
const deviceId = device.getString('id') ?? ''
uni.navigateTo({
url: `/pages/sport/student/device-detail?deviceId=${deviceId}`
})
}
// 返回上一页
const goBack = () => {
uni.navigateBack()
}
onLoad((options : OnLoadOptions) => {
userId.value = options['id'] ?? getCurrentUserId()
loadUserDevices()
})
onMounted(() => {
// Initialize screen width
screenWidth.value = uni.getSystemInfoSync().windowWidth
})
// Handle resize events for responsive design
onResize((size) => {
screenWidth.value = size.size.windowWidth
})
</script>
<style>
.device-management-page {
display: flex;
flex:1;
min-height: 100vh;
background: #f8f9fa;
}
.header {
flex-direction: row;
background-image: linear-gradient(to bottom right, #667eea, #764ba2);
padding: 20rpx 30rpx;
display: flex;
justify-content: space-between;
align-items: center;
color: white;
}
.header-left {
display: flex;
flex-direction: row;
align-items: center;
}
.header-left > * {
margin-right: 20rpx;
}
.back-btn,
.scan-btn {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.2);
padding: 15rpx 20rpx;
border-radius: 20rpx;
border: none;
color: white;
font-size: 28rpx;
}
.back-btn simple-icon,
.scan-btn simple-icon {
margin-right: 10rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: white;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 400rpx;
}
.loading-text {
font-size: 32rpx;
color: #8f9bb3;
}
.content {
padding: 30rpx;
}
/* Statistics */
.stats-section {
display: flex;
margin-bottom: 40rpx;
}
.stats-section .stat-card {
margin-right: 20rpx;
}
.stat-card {
flex: 1;
background: white;
border-radius: 20rpx;
padding: 30rpx 20rpx;
box-shadow: 0 8rpx 25rpx rgba(0, 0, 0, 0.1);
margin-right: 20rpx;
}
.stat-number { font-size: 44rpx;
font-weight: bold;
color: #667eea;
line-height: 1;
margin-bottom: 10rpx;
text-align: center;
}
.stat-label {
font-size: 24rpx;
color: #8f9bb3;
text-align: center;
}
/* Add Device Section */
.add-device-section {
margin-bottom: 40rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #2d3748;
margin-bottom: 20rpx;
}
.device-types {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.device-types .device-type-item {
width: 47%;
margin-right: 15rpx;
margin-bottom: 15rpx;
}
.device-type-item {
background: white;
border-radius: 16rpx;
padding: 25rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
transition: transform 0.2s;
margin-right: 20rpx;
}
.device-type-icon {
font-size: 40rpx;
margin-bottom: 10rpx;
text-align: center;
}
.device-type-name {
font-size: 28rpx;
font-weight: bold;
color: #2d3748;
margin-bottom: 5rpx;
text-align: center;
}
.device-type-desc {
font-size: 22rpx;
color: #718096;
line-height: 1.3;
text-align: center;
}
/* Devices Section */
.devices-section {
margin-bottom: 40rpx;
}
.empty-state {
background: white;
border-radius: 20rpx;
padding: 60rpx 40rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
text-align: center;
}
.empty-title {
font-size: 32rpx;
font-weight: bold;
color: #2d3748;
margin-bottom: 10rpx;
text-align: center;
}
.empty-subtitle {
font-size: 28rpx;
color: #8f9bb3;
line-height: 1.4;
margin-bottom: 30rpx;
text-align: center;
}
.scan-devices-btn {
background: #667eea;
color: white;
border: none;
border-radius: 25rpx;
padding: 25rpx 40rpx;
font-size: 28rpx;
font-weight: 400;
} .devices-list {
display: flex;
flex-direction: column;
}
.devices-list .device-item {
margin-bottom: 15rpx;
}
.device-item {
background: white;
border-radius: 20rpx;
padding: 25rpx;
display: flex;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
transition: transform 0.2s;
}
.device-item .device-icon {
margin-right: 20rpx;
}
.device-icon {
position: relative;
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f7fafc;
border-radius: 50%;
}
.device-emoji {
font-size: 32rpx;
}
.device-status {
position: absolute;
bottom: 0;
right: 0;
width: 24rpx;
height: 24rpx;
border-radius: 50%;
border: 3rpx solid white;
}
.status-active {
background: #48bb78;
}
.status-offline {
background: #a0aec0;
}
.device-info {
flex: 1;
}
.device-name {
font-size: 30rpx;
font-weight: bold;
color: #2d3748;
margin-bottom: 5rpx;
}
.device-type {
font-size: 26rpx;
color: #718096;
margin-bottom: 3rpx;
}
.device-status-text {
font-size: 24rpx;
color: #8f9bb3;
}
.device-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.device-actions > * {
margin-bottom: 10rpx;
}
.device-battery {
display: flex;
align-items: center;
}
.device-battery .battery-icon {
margin-right: 5rpx;
}
.battery-icon {
font-size: 20rpx;
}
.battery-level {
font-size: 22rpx;
color: #4a5568;
}
.action-btn {
background: #667eea;
color: white;
border: none;
border-radius: 15rpx;
padding: 10rpx 20rpx;
font-size: 24rpx;
}
/* Scanning Animation */
.scanning-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.scanning-content {
background: white;
border-radius: 20rpx;
padding: 60rpx 40rpx;
margin: 40rpx;
}
.scanning-animation {
position: relative;
width: 120rpx;
height: 120rpx;
margin: 0 auto 30rpx;
display: flex;
align-items: center;
justify-content: center;
}
.scanning-icon {
font-size: 48rpx;
z-index: 1;
}
.scanning-rings {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.ring {
position: absolute;
border: 2rpx solid #667eea;
border-radius: 50%;
opacity: 0;
animation: pulse 2s infinite;
}
.ring-1 {
top: 10rpx;
left: 10rpx;
right: 10rpx;
bottom: 10rpx;
animation-delay: 0s;
}
.ring-2 {
top: 5rpx;
left: 5rpx;
right: 5rpx;
bottom: 5rpx;
animation-delay: 0.5s;
}
.ring-3 {
top: 0;
left: 0;
right: 0;
bottom: 0;
animation-delay: 1s;
}
@keyframes pulse {
0% {
opacity: 1;
transform: scale(0.8);
}
100% {
opacity: 0;
transform: scale(1.2);
}
}
.scanning-text {
font-size: 32rpx;
font-weight: bold;
color: #2d3748;
margin-bottom: 10rpx;
text-align: center;
}
.scanning-hint {
font-size: 26rpx;
color: #718096;
line-height: 1.4;
margin-bottom: 30rpx;
text-align: center;
}
.stop-scan-btn {
background: #e53e3e;
color: white;
border: none;
border-radius: 25rpx;
padding: 25rpx 40rpx;
font-size: 28rpx;
font-weight: 400;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 40rpx;
}
.modal-content {
background: white;
border-radius: 24rpx;
width: 100%;
max-width: 600rpx;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 40rpx 40rpx 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e2e8f0;
}
.modal-title {
font-size: 36rpx;
font-weight: bold;
color: #2d3748;
}
.modal-close {
font-size: 48rpx;
color: #a0aec0;
line-height: 1;
padding: 10rpx;
}
.modal-body {
padding: 40rpx;
flex: 1;
}
.form-group {
margin-bottom: 30rpx;
}
.form-label {
font-size: 28rpx;
color: #4a5568;
font-weight: 400;
margin-bottom: 15rpx;
}
.form-input,
.form-textarea {
width: 100%;
padding: 25rpx 20rpx;
border: 2rpx solid #e2e8f0;
border-radius: 12rpx;
font-size: 28rpx;
color: #2d3748;
background: #f7fafc;
}
.form-textarea {
height: 120rpx;
resize: none;
}
.modal-footer {
padding: 20rpx 40rpx 40rpx;
display: flex;
border-top: 1px solid #e2e8f0;
}
.modal-footer .modal-button {
margin-right: 20rpx;
}
.modal-button {
flex: 1;
padding: 25rpx;
border-radius: 12rpx;
font-size: 28rpx;
font-weight: 400;
border: none;
margin-right: 20rpx;
}
.modal-button.cancel {
background: #f7fafc;
color: #4a5568;
}
.modal-button.confirm {
background: #667eea;
color: white;
}
</style>