Files
attractor/attractor-ui/pages/devices/devices.vue
2026-04-07 11:18:02 +08:00

230 lines
8.7 KiB
Vue
Raw 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-page">
<view class="search-row">
<view class="search-box">
<uni-icons type="search" size="16" color="#7b8794"></uni-icons>
<input class="search-input" placeholder="搜索设备名称或SN" v-model="searchKeyword" @confirm="fetchMyDevices" />
<button v-if="searchKeyword" class="clear-btn" @click="clearSearch">×</button>
</view>
<button class="scan-chip" @click="refreshScan">扫描连接</button>
</view>
<view class="section" v-if="scannedDevices.length">
<view class="section-head">
<text class="section-title">可连接设备</text>
<text class="section-sub">点击连接后自动写入后端</text>
</view>
<view v-for="s in scannedDevices" :key="s.deviceId" class="device-card" @click="askConnect(s)">
<image class="device-icon" :src="getIconForType()" mode="aspectFit" />
<view class="device-main">
<text class="device-name">{{ s.name }}</text>
<text class="device-meta">SN: {{ s.deviceId }}</text>
</view>
<view class="state-pill running">连接</view>
</view>
</view>
<scroll-view class="content" scroll-y>
<view class="section">
<view class="section-head">
<text class="section-title">我的设备</text>
<text class="section-sub">后端持久化数据</text>
</view>
<view v-if="deviceList.length === 0" class="empty-card">
<text>暂无设备请先扫描连接</text>
</view>
<view v-for="item in deviceList" :key="item.deviceId" class="device-card" @click="gotoDetail(item)">
<image class="device-icon" :src="getIconForType()" mode="aspectFit" />
<view class="device-main">
<text class="device-name">{{ item.deviceName || '未命名设备' }}</text>
<text class="device-meta">SN: {{ item.deviceSn || '--' }}</text>
<text class="device-meta" v-if="item.lastConnectedAt">最近连接{{ formatTime(item.lastConnectedAt) }}</text>
</view>
<view class="device-actions">
<view class="state-pill" :class="item.status === '0' ? 'running' : 'offline'">
{{ item.status === '0' ? '正常' : '停用' }}
</view>
<button class="delete-btn" @click.stop="removeDevice(item)">
<uni-icons type="trash" size="12" color="#64748b"></uni-icons>
<text>删除</text>
</button>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { listMyDevices, deleteMyDevices, addMyDevice, updateMyDevice } from '@/api/device'
export default {
data() {
return {
searchKeyword: '',
deviceList: [],
scannedDevices: [],
_bluetoothFoundHandler: null
}
},
onLoad() {
this.fetchMyDevices()
},
onUnload() {
this.stopScan()
},
methods: {
fetchMyDevices() {
const params = {}
if ((this.searchKeyword || '').trim()) params.deviceName = this.searchKeyword.trim()
listMyDevices(params).then(res => {
this.deviceList = Array.isArray(res.rows) ? res.rows : []
})
},
clearSearch() {
this.searchKeyword = ''
this.fetchMyDevices()
},
refreshScan() {
this.scannedDevices = []
this.startScan()
},
startScan() {
uni.openBluetoothAdapter({
success: () => {
uni.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false,
success: () => {
if (this._bluetoothFoundHandler) {
try { uni.offBluetoothDeviceFound(this._bluetoothFoundHandler) } catch (e) {}
}
this._bluetoothFoundHandler = res => {
const arr = res.devices || []
arr.forEach(dev => {
const id = dev.deviceId
const name = (dev.name || dev.localName || '').trim()
if (!id || !name) return
if (this.scannedDevices.some(s => s.deviceId === id)) return
this.scannedDevices.push({ deviceId: id, name })
})
}
uni.onBluetoothDeviceFound(this._bluetoothFoundHandler)
uni.showToast({ title: '扫描中...', icon: 'none' })
setTimeout(() => this.stopScan(), 10000)
}
})
},
fail: () => uni.showToast({ title: '请开启蓝牙权限', icon: 'none' })
})
},
stopScan() {
try { uni.stopBluetoothDevicesDiscovery({}) } catch (e) {}
if (this._bluetoothFoundHandler) {
try { uni.offBluetoothDeviceFound(this._bluetoothFoundHandler) } catch (e) {}
this._bluetoothFoundHandler = null
}
},
askConnect(device) {
uni.showModal({
title: '连接确认',
content: `连接设备「${device.name}」后将自动写入后端,是否继续?`,
success: res => {
if (res.confirm) this.connectDevice(device)
}
})
},
connectDevice(device) {
uni.showLoading({ title: '连接中...' })
uni.createBLEConnection({
deviceId: device.deviceId,
success: () => {
this.upsertDeviceToServer(device).finally(() => uni.hideLoading())
},
fail: () => {
uni.hideLoading()
uni.showToast({ title: '连接失败', icon: 'none' })
}
})
},
upsertDeviceToServer(device) {
const exist = this.deviceList.find(d => d.deviceSn === device.deviceId)
const payload = {
deviceName: device.name,
deviceSn: device.deviceId,
deviceType: 'attractor',
status: '0',
lastConnectedAt: new Date().toISOString()
}
if (exist) {
return updateMyDevice({ ...payload, deviceId: exist.deviceId }).then(() => {
uni.showToast({ title: '连接成功,已更新', icon: 'success' })
this.fetchMyDevices()
})
}
return addMyDevice(payload).then(() => {
uni.showToast({ title: '连接成功,已入库', icon: 'success' })
this.fetchMyDevices()
})
},
formatTime(ts) {
if (!ts) return ''
const d = new Date(ts)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const h = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${day} ${h}:${min}`
},
getIconForType() {
return '/static/images/app_icon_pack_schemeA_tealLogo/master_1024.png'
},
removeDevice(item) {
uni.showModal({
title: '删除设备',
content: '确认删除该设备吗?',
success: res => {
if (res.confirm) {
deleteMyDevices(item.deviceId).then(() => {
uni.showToast({ title: '删除成功', icon: 'none' })
this.fetchMyDevices()
})
}
}
})
},
gotoDetail(device) {
if (!device || !device.deviceSn) return
uni.navigateTo({ url: `/pages/devices/attractor/attractor?deviceId=${encodeURIComponent(device.deviceSn)}` })
}
}
}
</script>
<style scoped>
.device-page { min-height: 100vh; background: #f4f7f8; padding: 20rpx 20rpx 0; box-sizing: border-box; }
.search-row { display: flex; gap: 12rpx; align-items: center; margin-bottom: 18rpx; }
.search-box { flex: 1; height: 72rpx; display: flex; align-items: center; gap: 10rpx; padding: 0 16rpx; background: #fff; border-radius: 14rpx; border: 1rpx solid #e7eeef; }
.search-input { flex: 1; font-size: 24rpx; color: #0f172a; }
.clear-btn { border: none; background: transparent; font-size: 32rpx; color: #9ca3af; line-height: 1; }
.scan-chip { height: 72rpx; padding: 0 16rpx; border-radius: 14rpx; background: #e6f5f4; border: 1rpx solid #bfe9e7; display: flex; align-items: center; font-size: 22rpx; color: #0f7f7a; }
.content { height: calc(100vh - 240rpx); }
.section { margin-bottom: 20rpx; }
.section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12rpx; }
.section-title { font-size: 26rpx; font-weight: 600; color: #0f172a; }
.section-sub { font-size: 22rpx; color: #94a3b8; }
.empty-card { background: #fff; border-radius: 16rpx; border: 1rpx dashed #d6dde3; padding: 24rpx; text-align: center; color: #94a3b8; font-size: 24rpx; }
.device-card { display: flex; align-items: center; padding: 18rpx; border-radius: 18rpx; background: #fff; border: 1rpx solid #e7eeef; margin-bottom: 12rpx; }
.device-icon { width: 70rpx; height: 70rpx; border-radius: 16rpx; background: #f2f7f6; margin-right: 16rpx; }
.device-main { flex: 1; display: flex; flex-direction: column; gap: 4rpx; }
.device-name { font-size: 26rpx; font-weight: 600; color: #0f172a; }
.device-meta { font-size: 22rpx; color: #94a3b8; }
.device-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 10rpx; }
.state-pill { padding: 4rpx 12rpx; border-radius: 999rpx; font-size: 20rpx; font-weight: 600; background: #f1f5f9; color: #64748b; }
.state-pill.running { background: #ecfdf3; color: #059669; }
.state-pill.offline { background: #f1f5f9; color: #94a3b8; }
.delete-btn { display: inline-flex; align-items: center; gap: 6rpx; height: 44rpx; padding: 0 12rpx; border-radius: 12rpx; border: 1rpx solid #e2e8f0; background: #f8fafc; color: #64748b; font-size: 20rpx; }
</style>