Files
attractor/attractor-ui/pages/devices/devices.vue

230 lines
8.7 KiB
Vue
Raw Normal View History

2026-04-07 11:18:02 +08:00
<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>