Files
uni-pda/pages/settings/devices/scale.vue
T
2026-06-02 15:28:25 +08:00

918 lines
24 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="container">
<!-- ========== 状态栏 ========== -->
<view class="status-section">
<view class="status-item">
<text class="status-label">蓝牙适配器</text>
<text class="status-value" :class="adapterState.available ? 'available' : ''">
{{ adapterState.available ? '就绪' : '未就绪' }}
</text>
</view>
<view class="status-item">
<text class="status-label">称重设备</text>
<text class="status-value" :class="{ connected: connected }">
{{ connected ? '已连接' : '未连接' }}
</text>
</view>
</view>
<!-- ========== 蓝牙操作按钮 ========== -->
<view class="action-section">
<button type="primary" size="mini" @click="initBluetooth" :disabled="isInitializing">
{{ isInitializing ? '初始化中...' : '初始化蓝牙' }}
</button>
<button v-if="adapterState.available && !adapterState.discovering" type="primary" size="mini" @click="startDiscovery">
搜索设备
</button>
<button v-if="adapterState.discovering" type="default" size="mini" @click="stopDiscovery">
停止搜索
</button>
<button v-if="connected" type="warn" size="mini" @click="disconnectScale">
断开连接
</button>
</view>
<!-- ========== 设备列表 ========== -->
<view class="section" v-if="!connected">
<view class="section-header">
<text class="section-title">可用设备 ({{ deviceList.length }})</text>
</view>
<scroll-view class="device-list" scroll-y>
<view
class="device-item"
v-for="device in deviceList"
:key="device.deviceId"
:class="{ connecting: connectingDeviceId === device.deviceId }"
@click="connectDevice(device)">
<text class="device-name">{{ device.name || device.localName || '未知设备' }}</text>
<text class="device-id">{{ device.deviceId }}</text>
<text class="connect-hint" v-if="connectingDeviceId === device.deviceId">连接中...</text>
</view>
<view class="empty-tip" v-if="deviceList.length === 0 && !adapterState.discovering">
<text>暂无设备请点击搜索</text>
</view>
</scroll-view>
</view>
<!-- ========== 称重设备配置区 ========== -->
<view class="section" v-else>
<view class="section-header">
<text class="section-title">设备配置</text>
<text class="device-name-tag">{{ connectedDeviceName }}</text>
</view>
<!-- 服务配置区域 -->
<view class="service-config-section" v-if="serviceList.length > 0">
<!-- 当前配置显示 -->
<view class="current-config">
<view class="config-item">
<text class="config-label">服务UUID:</text>
<text class="config-value" :class="{ active: notifyServiceId }">{{ notifyServiceId || '未选择' }}</text>
</view>
<view class="config-item">
<text class="config-label">通知特征值:</text>
<text class="config-value" :class="{ active: notifyCharacteristicId }">{{ notifyCharacteristicId || '未选择' }}</text>
</view>
<view class="config-item">
<text class="config-label">写入特征值:</text>
<text class="config-value" :class="{ active: writeCharacteristicId }">{{ writeCharacteristicId || '未选择(可选)' }}</text>
</view>
</view>
<!-- 服务列表 -->
<view class="service-list">
<text class="list-title">服务列表 ({{ serviceList.length }})</text>
<scroll-view class="service-scroll" scroll-x>
<view
class="service-tag"
v-for="(service, index) in serviceList"
:key="index"
:class="{ selected: selectedServiceId === service.uuid }"
@click="selectService(service)">
<text class="service-index">{{ index + 1 }}</text>
<text class="service-uuid">{{ service.uuid }}</text>
<text class="service-type" v-if="service.isPrimary"></text>
</view>
</scroll-view>
</view>
<!-- 特征值列表优先显示notify类型 -->
<view class="characteristic-list" v-if="characteristicList.length > 0">
<text class="list-title">特征值列表 ({{ characteristicList.length }})</text>
<scroll-view class="characteristic-scroll" scroll-y>
<view
class="characteristic-item"
v-for="(char, index) in characteristicList"
:key="index"
:class="{
selected: notifyCharacteristicId === char.uuid || writeCharacteristicId === char.uuid,
notifiable: isNotifiable(char),
writable: isWritable(char)
}"
@click="selectCharacteristic(char)">
<view class="char-info">
<text class="char-uuid">{{ char.uuid }}</text>
<view class="char-props">
<text class="prop-tag" v-if="char.properties.write">write</text>
<text class="prop-tag" v-if="char.properties.writeNoResponse">writeNoResp</text>
<text class="prop-tag" v-if="char.properties.read">read</text>
<text class="prop-tag notify" v-if="char.properties.notify">notify</text>
<text class="prop-tag indicate" v-if="char.properties.indicate">indicate</text>
</view>
</view>
<view class="char-actions">
<text class="select-btn notify-btn" v-if="notifyCharacteristicId === char.uuid">通知</text>
<text class="select-btn write-btn" v-else-if="writeCharacteristicId === char.uuid">写入</text>
<text class="select-btn" v-else @click.stop="selectCharacteristic(char)">选择</text>
</view>
</view>
</scroll-view>
</view>
<!-- 保存配置按钮 -->
<view class="save-btn-wrap">
<button type="primary" @click="saveCurrentConfig" :disabled="!notifyCharacteristicId">
保存配置
</button>
<button type="default" @click="goToTestPage" v-if="notifyCharacteristicId">
去测试页面
</button>
</view>
</view>
</view>
<!-- ========== 操作日志 ========== -->
<view class="section log-section">
<view class="section-header">
<text class="section-title">操作日志</text>
<button size="mini" @click="clearLog">清空</button>
</view>
<scroll-view class="log-list" scroll-y>
<text class="log-item"
:class="log.type"
v-for="(log, index) in logList"
:key="index">
[{{ log.time }}] {{ log.message }}
</text>
</scroll-view>
</view>
</view>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
/**
* 蓝牙称重设备配置页面
*
* 功能说明:
* 1. 初始化蓝牙适配器,搜索附近的蓝牙称重设备
* 2. 连接选定的设备,获取并配置蓝牙服务和特征值(notify类型)
* 3. 支持保存/加载配置到 Vuex store
* 4. 显示操作日志便于调试
*/
export default {
data() {
return {
// ========== 蓝牙适配器状态 ==========
adapterState: {
available: false,
discovering: false
},
isInitializing: false,
// ========== 设备列表 ==========
deviceList: [],
connectingDeviceId: '',
// ========== 连接状态 ==========
connectedDeviceId: '',
connectedDeviceName: '',
connected: false,
// ========== 服务和特征值 ==========
notifyCharacteristicId: '',
notifyServiceId: '',
writeCharacteristicId: '',
writeServiceId: '',
serviceList: [],
characteristicList: [],
selectedServiceId: '',
// ========== 操作日志 ==========
logList: [],
}
},
computed: {
...mapGetters('scale', ['scaleServiceId', 'scaleCharacteristicId'])
},
onLoad() {
this.initBluetooth()
this.setupListeners()
this.loadSavedConfig()
},
onUnload() {
this.cleanup()
},
methods: {
...mapActions('scale', ['saveScaleConfig']),
/**
* 添加日志记录
*/
addLog(message, type = 'info') {
const now = new Date()
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
this.logList.unshift({ time, message, type })
if (this.logList.length > 100) {
this.logList.pop()
}
console.log(`[BLE Scale] ${message}`)
},
clearLog() {
this.logList = []
},
/**
* 设置蓝牙事件监听器
*/
setupListeners() {
uni.onBluetoothAdapterStateChange((res) => {
this.addLog(`适配器: available=${res.available}, discovering=${res.discovering}`)
this.adapterState.available = res.available
this.adapterState.discovering = res.discovering
})
uni.onBluetoothDeviceFound((res) => {
const devices = res.devices || []
devices.forEach(device => {
if (!device.name && !device.localName) return
const index = this.deviceList.findIndex(d => d.deviceId === device.deviceId)
if (index === -1) {
this.deviceList.push(device)
this.addLog(`发现: ${device.name || device.localName}`)
}
})
})
uni.onBLEConnectionStateChange((res) => {
if (!res.connected && this.connectedDeviceId === res.deviceId) {
this.connected = false
this.connectedDeviceId = ''
this.connectedDeviceName = ''
this.addLog('设备已断开', 'warn')
}
})
},
/**
* 初始化蓝牙适配器
*/
initBluetooth() {
if (this.isInitializing) return
this.isInitializing = true
this.addLog('初始化蓝牙...')
uni.openBluetoothAdapter({
success: (res) => {
this.adapterState.available = true
this.isInitializing = false
this.addLog('蓝牙初始化成功')
this.getKnownDevices()
},
fail: (err) => {
this.isInitializing = false
this.adapterState.available = false
this.addLog(`初始化失败: ${err.errMsg}`, 'error')
}
})
},
/**
* 获取已知的蓝牙设备
*/
getKnownDevices() {
uni.getBluetoothDevices({
success: (res) => {
if (res.devices && res.devices.length > 0) {
this.deviceList = res.devices.filter(d => d.name || d.localName)
}
}
})
},
/**
* 开始搜索蓝牙设备
*/
startDiscovery() {
if (this.adapterState.discovering) return
this.deviceList = []
this.addLog('开始搜索...')
uni.startBluetoothDevicesDiscovery({
allowDuplicatesKey: true,
success: () => {
this.adapterState.discovering = true
this.addLog('搜索中...')
setTimeout(() => {
if (this.adapterState.discovering) {
this.stopDiscovery()
}
}, 15000)
},
fail: (err) => {
this.addLog(`搜索失败: ${err.errMsg}`, 'error')
}
})
},
/**
* 停止搜索蓝牙设备
*/
stopDiscovery() {
uni.stopBluetoothDevicesDiscovery({
success: () => {
this.adapterState.discovering = false
this.addLog('已停止搜索')
}
})
},
/**
* 连接蓝牙设备
*/
connectDevice(device) {
if (this.connected) return
this.connectingDeviceId = device.deviceId
this.addLog(`连接 ${device.name || device.localName}...`)
uni.createBLEConnection({
deviceId: device.deviceId,
timeout: 10000,
success: () => {
this.connected = true
this.connectedDeviceId = device.deviceId
this.connectedDeviceName = device.name || device.localName
this.connectingDeviceId = ''
this.addLog('连接成功')
this.getDeviceServices()
},
fail: (err) => {
this.connectingDeviceId = ''
this.addLog(`连接失败: ${err.errMsg}`, 'error')
}
})
},
/**
* 断开连接
*/
disconnectScale() {
if (!this.connectedDeviceId) return
this.addLog('断开连接...')
uni.closeBLEConnection({
deviceId: this.connectedDeviceId,
success: () => {
this.connected = false
this.connectedDeviceId = ''
this.connectedDeviceName = ''
this.serviceList = []
this.characteristicList = []
this.addLog('已断开')
},
fail: (err) => {
this.addLog(`断开失败: ${err.errMsg}`, 'error')
}
})
},
/**
* 获取设备服务列表
*/
getDeviceServices() {
this.addLog('获取服务...')
uni.getBLEDeviceServices({
deviceId: this.connectedDeviceId,
success: (res) => {
this.serviceList = res.services
this.addLog(`找到 ${res.services.length} 个服务`)
// 自动选择主服务
const primaryService = res.services.find(s => s.isPrimary)
if (primaryService) {
this.selectService(primaryService)
}
},
fail: (err) => {
this.addLog(`获取服务失败: ${err.errMsg}`, 'error')
}
})
},
/**
* 选择服务并获取特征值
*/
selectService(service) {
this.selectedServiceId = service.uuid
this.addLog(`选择服务: ${service.uuid}`)
uni.getBLEDeviceCharacteristics({
deviceId: this.connectedDeviceId,
serviceId: service.uuid,
success: (res) => {
this.characteristicList = res.characteristics
// 优先查找notify或indicate特征值(称重设备通常用通知方式传输数据)
const notifyChar = res.characteristics.find(c => c.properties.notify)
if (notifyChar) {
this.notifyCharacteristicId = notifyChar.uuid
this.notifyServiceId = service.uuid
this.addLog(`notify特征值: ${notifyChar.uuid}`)
} else {
const indicateChar = res.characteristics.find(c => c.properties.indicate)
if (indicateChar) {
this.notifyCharacteristicId = indicateChar.uuid
this.notifyServiceId = service.uuid
this.addLog(`indicate特征值: ${indicateChar.uuid}`)
} else {
this.addLog(`未找到notify特征值,特征值数: ${res.characteristics.length}`, 'warn')
}
}
// 同时查找可写特征值(用于发送命令)
const writeChar = res.characteristics.find(c => c.properties.write || c.properties.writeNoResponse)
if (writeChar) {
this.writeCharacteristicId = writeChar.uuid
this.writeServiceId = service.uuid
this.addLog(`写入特征值: ${writeChar.uuid}`)
}
},
fail: (err) => {
this.addLog(`获取特征值失败: ${err.errMsg}`, 'error')
}
})
},
/**
* 选择特征值(点击切换通知/写入模式)
*/
selectCharacteristic(char) {
// 如果当前已选择此特征值,切换模式
if (this.notifyCharacteristicId === char.uuid) {
// 如果是可写的,切换到写入模式
if (this.isWritable(char)) {
this.notifyCharacteristicId = ''
this.writeCharacteristicId = char.uuid
this.writeServiceId = this.selectedServiceId
this.addLog(`切换为写入特征值: ${char.uuid}`)
uni.showToast({ title: '已切换为写入模式', icon: 'none' })
} else {
this.addLog('此特征值不可写入', 'warn')
}
} else if (this.writeCharacteristicId === char.uuid) {
// 切换回通知模式
this.writeCharacteristicId = ''
this.notifyCharacteristicId = char.uuid
this.notifyServiceId = this.selectedServiceId
this.addLog(`切换为通知特征值: ${char.uuid}`)
uni.showToast({ title: '已切换为通知模式', icon: 'none' })
} else {
// 默认选择为通知模式
if (this.isNotifiable(char)) {
this.notifyCharacteristicId = char.uuid
this.notifyServiceId = this.selectedServiceId
this.addLog(`已选择通知特征值: ${char.uuid}`)
} else if (this.isWritable(char)) {
this.writeCharacteristicId = char.uuid
this.writeServiceId = this.selectedServiceId
this.addLog(`已选择写入特征值: ${char.uuid}`)
} else {
this.notifyCharacteristicId = char.uuid
this.notifyServiceId = this.selectedServiceId
this.addLog(`已选择特征值: ${char.uuid}`, 'warn')
}
uni.showToast({ title: '特征值已选择', icon: 'success' })
}
},
/**
* 检查特征值是否为notify/indicate类型
*/
isNotifiable(char) {
return char.properties && (char.properties.notify || char.properties.indicate)
},
/**
* 检查特征值是否可写
*/
isWritable(char) {
return char.properties && (char.properties.write || char.properties.writeNoResponse)
},
/**
* 跳转到测试页面
*/
goToTestPage() {
uni.navigateTo({
url: '/pages/settings/devices/scaleTest'
})
},
/**
* 保存当前配置
*/
async saveCurrentConfig() {
if (!this.connected) {
uni.showToast({ title: '请先连接设备', icon: 'none' })
return
}
if (!this.notifyServiceId || !this.notifyCharacteristicId) {
uni.showToast({ title: '请先选择服务和特征值', icon: 'none' })
return
}
const config = {
deviceId: this.connectedDeviceId,
deviceName: this.connectedDeviceName,
serviceId: this.notifyServiceId,
characteristicId: this.notifyCharacteristicId,
writeCharacteristicId: this.writeCharacteristicId || '',
connected: true,
lastConfigured: new Date().toISOString()
}
try {
await this.saveScaleConfig(config)
this.addLog(`配置已保存: ${this.connectedDeviceName}`, 'success')
uni.showToast({
title: '配置已保存',
icon: 'success',
duration: 2000
})
} catch (error) {
this.addLog(`保存配置失败: ${error.message}`, 'error')
}
},
/**
* 加载保存的配置
*/
loadSavedConfig() {
if (this.scaleServiceId) {
this.addLog(`已保存配置: 服务=${this.scaleServiceId}`)
}
},
/**
* 清理资源
*/
cleanup() {
if (this.connected) {
this.disconnectScale()
}
if (this.adapterState.discovering) {
this.stopDiscovery()
}
}
}
}
</script>
<style scoped>
/* ========== 页面容器 ========== */
.container {
padding: 20rpx;
background: #f5f5f5;
min-height: 100vh;
}
/* ========== 状态栏 ========== */
.status-section {
display: flex;
background: #fff;
padding: 20rpx;
border-radius: 12rpx;
margin-bottom: 20rpx;
}
.status-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.status-label {
font-size: 24rpx;
color: #999;
margin-bottom: 8rpx;
}
.status-value {
font-size: 28rpx;
font-weight: bold;
color: #666;
}
.status-value.available {
color: #007aff;
}
.status-value.connected {
color: #34c759;
}
/* ========== 操作按钮区域 ========== */
.action-section {
display: flex;
gap: 16rpx;
margin-bottom: 20rpx;
flex-wrap: wrap;
}
.action-section button {
flex: 1;
min-width: 150rpx;
}
/* ========== 通用区块样式 ========== */
.section {
background: #fff;
border-radius: 12rpx;
padding: 20rpx;
margin-bottom: 20rpx;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.section-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
}
.device-name-tag {
font-size: 24rpx;
color: #34c759;
}
/* ========== 设备列表 ========== */
.device-list {
max-height: 300rpx;
}
.device-item {
padding: 24rpx;
border-bottom: 1rpx solid #eee;
}
.device-item:active {
background: #f5f5f5;
}
.device-item.connecting {
background: #e8f4ff;
}
.device-name {
font-size: 28rpx;
font-weight: bold;
color: #333;
display: block;
}
.device-id {
font-size: 22rpx;
color: #999;
word-break: break-all;
}
.connect-hint {
font-size: 24rpx;
color: #007aff;
}
.empty-tip {
text-align: center;
padding: 40rpx;
color: #999;
}
/* ========== 预设设备提示 ========== */
.preset-tip {
background: #fffbe6;
border: 1rpx solid #ffe58f;
border-radius: 8rpx;
padding: 16rpx;
margin-bottom: 20rpx;
}
.preset-title {
font-size: 24rpx;
color: #d48806;
display: block;
margin-bottom: 12rpx;
}
.preset-item {
padding: 8rpx 0;
border-bottom: 1rpx dashed #ffe58f;
}
.preset-item:last-child {
border-bottom: none;
}
.preset-name {
font-size: 24rpx;
color: #333;
font-weight: bold;
display: block;
}
.preset-uuid {
font-size: 20rpx;
color: #666;
font-family: 'Courier New', monospace;
display: block;
word-break: break-all;
}
/* ========== 服务配置区域 ========== */
.service-config-section {
padding: 16rpx;
background: #f8f8f8;
border-radius: 8rpx;
}
.current-config {
padding: 16rpx;
background: #fff;
border-radius: 8rpx;
margin-bottom: 16rpx;
}
.config-item {
display: flex;
margin-bottom: 8rpx;
}
.config-label {
font-size: 24rpx;
color: #666;
min-width: 140rpx;
}
.config-value {
font-size: 24rpx;
color: #999;
font-family: 'Courier New', monospace;
word-break: break-all;
flex: 1;
}
.config-value.active {
color: #34c759;
}
.service-list {
margin-bottom: 16rpx;
}
.list-title {
font-size: 26rpx;
color: #333;
margin-bottom: 12rpx;
display: block;
font-weight: bold;
}
.service-scroll {
width: 100%;
white-space: nowrap;
}
.service-tag {
display: inline-flex;
align-items: center;
padding: 12rpx 20rpx;
background: #fff;
border: 2rpx solid #ddd;
border-radius: 8rpx;
margin-right: 16rpx;
white-space: nowrap;
}
.service-tag.selected {
border-color: #007aff;
background: #e8f4ff;
}
.service-index {
font-size: 24rpx;
color: #007aff;
font-weight: bold;
margin-right: 12rpx;
}
.service-uuid {
font-size: 22rpx;
font-family: 'Courier New', monospace;
color: #333;
max-width: 300rpx;
overflow: hidden;
text-overflow: ellipsis;
}
.service-type {
font-size: 20rpx;
color: #fff;
background: #34c759;
padding: 4rpx 8rpx;
border-radius: 4rpx;
margin-left: 12rpx;
}
/* ========== 特征值列表 ========== */
.characteristic-list {
margin-bottom: 16rpx;
}
.characteristic-scroll {
max-height: 300rpx;
background: #fff;
border-radius: 8rpx;
}
.characteristic-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx;
border-bottom: 1rpx solid #eee;
}
.characteristic-item:last-child {
border-bottom: none;
}
.characteristic-item.selected {
background: #e8f4ff;
}
.characteristic-item.notifiable {
border-left: 4rpx solid #007aff;
}
.characteristic-item.writable {
border-left: 4rpx solid #ff9500;
}
.char-info {
flex: 1;
}
.char-uuid {
font-size: 24rpx;
font-family: 'Courier New', monospace;
color: #333;
display: block;
margin-bottom: 8rpx;
word-break: break-all;
}
.char-props {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
}
.prop-tag {
font-size: 20rpx;
padding: 4rpx 8rpx;
background: #f0f0f0;
border-radius: 4rpx;
color: #666;
}
.prop-tag.notify {
background: #e6f7ff;
color: #007aff;
}
.prop-tag.indicate {
background: #f6ffed;
color: #52c41a;
}
.char-actions {
display: flex;
align-items: center;
}
.select-btn {
font-size: 24rpx;
color: #007aff;
padding: 8rpx 16rpx;
}
.notify-btn {
color: #34c759;
font-weight: bold;
}
.write-btn {
color: #ff9500;
font-weight: bold;
}
/* ========== 保存按钮 ========== */
.save-btn-wrap {
margin-top: 20rpx;
display: flex;
gap: 20rpx;
}
.save-btn-wrap button {
flex: 1;
}
/* ========== 日志区域 ========== */
.log-section {
min-height: 200rpx;
}
.log-list {
height: 250rpx;
background: #1c1c1c;
border-radius: 8rpx;
padding: 16rpx;
}
.log-item {
display: block;
font-size: 22rpx;
font-family: 'Courier New', monospace;
color: #fff;
margin-bottom: 8rpx;
line-height: 1.4;
}
.log-item.success {
color: #34c759;
}
.log-item.warn {
color: #ff9500;
}
.log-item.error {
color: #ff3b30;
}
</style>