230 lines
8.7 KiB
Vue
230 lines
8.7 KiB
Vue
<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>
|