20260905:微信小程序-蓝牙采集网关
This commit is contained in:
parent
d6377494b4
commit
21cd8337da
@ -65,7 +65,9 @@
|
|||||||
"Bash(for REF in \"http://localhost:8080/\" \"https://dev.fastbee.cn/\" \"https://www.tianditu.gov.cn/\")",
|
"Bash(for REF in \"http://localhost:8080/\" \"https://dev.fastbee.cn/\" \"https://www.tianditu.gov.cn/\")",
|
||||||
"Read(//tmp/**)",
|
"Read(//tmp/**)",
|
||||||
"Bash(node -e ' *)",
|
"Bash(node -e ' *)",
|
||||||
"Bash(read c *)"
|
"Bash(read c *)",
|
||||||
|
"Bash(tar xzf *)",
|
||||||
|
"mcp__filesystem__create_directory"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
359
.claude/skills/yack-mini-dev.md
Normal file
359
.claude/skills/yack-mini-dev.md
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
# yack-mini 开发指南
|
||||||
|
|
||||||
|
微信小程序蓝牙网关项目,采集 BLE 设备数据透传上报至 yack 平台。
|
||||||
|
|
||||||
|
## 项目定位
|
||||||
|
|
||||||
|
- **名称**:yack-mini
|
||||||
|
- **技术栈**:原生微信小程序
|
||||||
|
- **目标**:轻量化蓝牙网关,透传数据至 yack
|
||||||
|
- **与 yack-app 区别**:yack-app 是全功能移动端(UniApp),yack-mini 是轻量蓝牙网关(原生小程序)
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ BLE ┌──────────────────┐ wxs ┌─────────────────┐
|
||||||
|
│ BLE 设备 │ ←──────→ │ yack-mini │ ←──────→ │ yack 平台 │
|
||||||
|
│ (传感器) │ │ (微信小程序) │ MQTT │ (数据存储) │
|
||||||
|
└─────────────┘ └──────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
miniprogram/
|
||||||
|
├── app.js # 应用入口
|
||||||
|
├── app.json # 全局配置
|
||||||
|
├── app.wxss # 全局样式
|
||||||
|
├── project.config.json # 项目配置(需修改 appid)
|
||||||
|
├── sitemap.json # 站点地图
|
||||||
|
├── env.config.js # 环境配置(API/MQTT 地址)
|
||||||
|
│
|
||||||
|
├── pages/
|
||||||
|
│ ├── login/ # 登录页(首次使用时获取 userId)
|
||||||
|
│ │ ├── login.js
|
||||||
|
│ │ ├── login.wxml
|
||||||
|
│ │ ├── login.wxss
|
||||||
|
│ │ └── login.json
|
||||||
|
│ │
|
||||||
|
│ ├── index/ # 首页(设备列表)
|
||||||
|
│ │ ├── index.js
|
||||||
|
│ │ ├── index.wxml
|
||||||
|
│ │ ├── index.wxss
|
||||||
|
│ │ └── index.json
|
||||||
|
│ │
|
||||||
|
│ └── device/
|
||||||
|
│ ├── scan/ # 扫描设备页
|
||||||
|
│ │ ├── scan.js
|
||||||
|
│ │ ├── scan.wxml
|
||||||
|
│ │ ├── scan.wxss
|
||||||
|
│ │ └── scan.json
|
||||||
|
│ │
|
||||||
|
│ └── detail/ # 设备详情/实时数据页
|
||||||
|
│ ├── detail.js
|
||||||
|
│ ├── detail.wxml
|
||||||
|
│ ├── detail.wxss
|
||||||
|
│ └── detail.json
|
||||||
|
│
|
||||||
|
├── services/ # 核心服务模块
|
||||||
|
│ ├── ble.js # 蓝牙采集模块
|
||||||
|
│ │ • initBluetooth() # 初始化蓝牙适配器
|
||||||
|
│ │ • scanDevices(uuids, timeout) # 扫描设备
|
||||||
|
│ │ • stopScan() # 停止扫描
|
||||||
|
│ │ • connectDevice(id, serviceId) # 连接设备
|
||||||
|
│ │ • onData(callback) # 订阅数据通知(非 subscribeData)
|
||||||
|
│ │ • onStateChange(cb) # 状态变化回调
|
||||||
|
│ │ • writeCommand(hex) # 写入指令
|
||||||
|
│ │ • disconnect() # 断开连接
|
||||||
|
│ │ • closeBluetooth() # 关闭蓝牙适配器
|
||||||
|
│ │ • getState() # 获取当前状态
|
||||||
|
│ │ • isConnected() # 是否已连接
|
||||||
|
│ │
|
||||||
|
│ └── mqtt-client.js # MQTT 连接模块
|
||||||
|
│ • connect(options) # 连接 Broker
|
||||||
|
│ • publish(topic, msg, opts) # 发布消息
|
||||||
|
│ • subscribe(topic, cb, opts) # 订阅主题
|
||||||
|
│ • unsubscribe(topic) # 取消订阅
|
||||||
|
│ • onMessage(cb) # 消息回调
|
||||||
|
│ • onStateChange(cb) # 状态变化回调
|
||||||
|
│ • disconnect() # 断开连接
|
||||||
|
│ • getState() # 获取当前状态
|
||||||
|
│ • isConnected() # 是否已连接
|
||||||
|
│
|
||||||
|
├── utils/
|
||||||
|
│ ├── hex.js # 十六进制工具
|
||||||
|
│ │ • arrayBufferToHex(buf) # ArrayBuffer → Hex
|
||||||
|
│ │ • hexToArrayBuffer(hex) # Hex → ArrayBuffer
|
||||||
|
│ │
|
||||||
|
│ ├── storage.js # 本地存储
|
||||||
|
│ │ • getToken/setToken/removeToken # Token 管理
|
||||||
|
│ │ • getUserInfo/setUserInfo/removeUserInfo # 用户信息
|
||||||
|
│ │ • getDeviceList/saveDeviceList # 设备列表
|
||||||
|
│ │ • addDevice(device) # 添加设备(非 saveDevice)
|
||||||
|
│ │ • removeDevice/updateDevice/getDevice
|
||||||
|
│ │ • cachePendingData/getPendingData/clearPendingData # 离线缓存
|
||||||
|
│ │ • clearAll() # 清除所有数据
|
||||||
|
│ │
|
||||||
|
│ ├── request.js # HTTP 请求模块(仅登录使用)
|
||||||
|
│ │ • login(username, password, code, uuid) # 登录
|
||||||
|
│ │ • getInfo() # 获取用户信息
|
||||||
|
│ │ • captchaImage() # 获取验证码
|
||||||
|
│ │
|
||||||
|
│ └── auth.js # MQTT 凭证生成
|
||||||
|
│ • generateClientId(serialNumber, productId, userId) # 生成 clientId
|
||||||
|
│ • getMqttCredentials(serialNumber, productId, productSecret) # 获取凭证
|
||||||
|
│ • getMqttCredentialsFromDevice(device) # 从设备配置获取凭证
|
||||||
|
│ • buildPropertyPostTopic(productId, serialNumber) # 构建上报 Topic
|
||||||
|
│ • buildFunctionGetTopic(productId, serialNumber) # 构建指令 Topic
|
||||||
|
│ • buildPropertyGetTopic(productId, serialNumber) # 构建读取 Topic
|
||||||
|
│ • checkLoginStatus() # 检查登录状态
|
||||||
|
│ • getCurrentUserId() # 获取当前用户 ID
|
||||||
|
│
|
||||||
|
├── components/ # 公共组件
|
||||||
|
│ └── device-card/ # 设备卡片组件
|
||||||
|
│
|
||||||
|
├── libs/ # 第三方库
|
||||||
|
│ └── mqtt.min.js # MQTT 客户端(预置,非 npm 安装)
|
||||||
|
│
|
||||||
|
└── assets/ # 静态资源
|
||||||
|
└── images/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 登录机制说明
|
||||||
|
|
||||||
|
### 设计原则
|
||||||
|
|
||||||
|
**登录仅用于首次获取 userId,后续无需重复登录。**
|
||||||
|
|
||||||
|
- userId 用于生成 MQTT clientId,是设备上报数据的必要参数
|
||||||
|
- 登录成功后,userId 永久保存在本地
|
||||||
|
- 后续启动直接读取本地 userId,无需网络请求
|
||||||
|
- Token 不参与后续业务逻辑(仅登录时使用)
|
||||||
|
|
||||||
|
### 登录流程
|
||||||
|
|
||||||
|
```
|
||||||
|
首次启动:
|
||||||
|
检查本地 userId → 无 → 显示登录页 → 输入账号密码 → 获取 userId → 保存本地 → 进入设备列表
|
||||||
|
|
||||||
|
后续启动:
|
||||||
|
检查本地 userId → 有 → 直接进入设备列表(无需登录)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 退出登录
|
||||||
|
|
||||||
|
如需切换账号,在设置页或设备列表页提供"退出登录"按钮,调用:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const storage = require('../../utils/storage.js');
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
function logout() {
|
||||||
|
storage.clearAll(); // 清除本地所有数据
|
||||||
|
wx.reLaunch({ url: '/pages/login/login' }); // 跳转登录页
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键技术实现
|
||||||
|
|
||||||
|
### 1. 蓝牙 BLE 采集(services/ble.js)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 核心流程
|
||||||
|
wx.openBluetoothAdapter() // 初始化
|
||||||
|
→ wx.startBluetoothDevicesDiscovery() // 扫描
|
||||||
|
→ wx.createBLEConnection() // 连接
|
||||||
|
→ wx.getBLEDeviceServices() // 获取服务
|
||||||
|
→ wx.getBLEDeviceCharacteristics() // 获取特征值
|
||||||
|
→ wx.notifyBLECharacteristicValueChange() // 启用通知
|
||||||
|
→ wx.onBLECharacteristicValueChange() // 监听数据
|
||||||
|
```
|
||||||
|
|
||||||
|
**使用示例:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { bleService } = require('../../services/ble.js');
|
||||||
|
|
||||||
|
// 初始化并连接
|
||||||
|
await bleService.initBluetooth();
|
||||||
|
bleService.onData((data) => {
|
||||||
|
console.log('收到数据:', data.hex); // hex 字符串
|
||||||
|
});
|
||||||
|
|
||||||
|
const devices = await bleService.scanDevices([], 5000);
|
||||||
|
await bleService.connectDevice(devices[0].deviceId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. MQTT 连接(services/mqtt-client.js)
|
||||||
|
|
||||||
|
**库文件**:使用预置的 `libs/mqtt.min.js`(非 npm 安装)
|
||||||
|
|
||||||
|
**连接配置:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
clientId: 'S&{设备编号}&{产品ID}&{用户ID}', // 简单认证
|
||||||
|
username: '{设备编号}',
|
||||||
|
password: '{产品密钥}',
|
||||||
|
clean: true,
|
||||||
|
keepalive: 30
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**协议**:`wxs://domain.com:8084/mqtt`(微信小程序专用)
|
||||||
|
|
||||||
|
**使用示例:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { mqttClient } = require('../../services/mqtt-client.js');
|
||||||
|
const auth = require('../../utils/auth.js');
|
||||||
|
|
||||||
|
// 获取凭证
|
||||||
|
const credentials = auth.getMqttCredentialsFromDevice(device);
|
||||||
|
|
||||||
|
// 连接
|
||||||
|
await mqttClient.connect({
|
||||||
|
clientId: credentials.clientId,
|
||||||
|
username: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发布数据
|
||||||
|
const topic = auth.buildPropertyPostTopic(device.productId, device.serialNumber);
|
||||||
|
await mqttClient.publish(topic, hexData, { qos: 1 });
|
||||||
|
|
||||||
|
// 订阅指令
|
||||||
|
const cmdTopic = auth.buildFunctionGetTopic(device.productId, device.serialNumber);
|
||||||
|
await mqttClient.subscribe(cmdTopic, (topic, message) => {
|
||||||
|
console.log('收到指令:', message);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 数据透传格式
|
||||||
|
|
||||||
|
| 场景 | 上报格式 | Topic |
|
||||||
|
|:-----|:--------|:------|
|
||||||
|
| Modbus-RTU | Hex 字符串(原始帧) | `/{productId}/{sn}/property/post` |
|
||||||
|
| 其他协议 | Hex 字符串(原始数据) | 同上(平台脚本解析) |
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Modbus 帧上报
|
||||||
|
mqttClient.publish('/1/BLE001/property/post', '010304009A005B8B7C');
|
||||||
|
|
||||||
|
// 私有协议数据上报
|
||||||
|
mqttClient.publish('/1/BLE002/property/post', '009A005B');
|
||||||
|
```
|
||||||
|
|
||||||
|
## yack 平台配置
|
||||||
|
|
||||||
|
### 产品配置
|
||||||
|
|
||||||
|
1. **创建产品**
|
||||||
|
- 产品名称:蓝牙传感器
|
||||||
|
- 协议:`MODBUS-RTU` 或 `JSON`(取决于设备协议)
|
||||||
|
|
||||||
|
2. **定义物模型**
|
||||||
|
- 属性:temperature、humidity 等
|
||||||
|
- 标识符:与 Modbus 配置/脚本解析对应
|
||||||
|
|
||||||
|
3. **Modbus 配置**(`iot_modbus_config`,仅 Modbus 设备)
|
||||||
|
- 从站地址、功能码、寄存器地址、数据类型
|
||||||
|
|
||||||
|
4. **规则脚本**(`iot_script`,仅非 Modbus 设备)
|
||||||
|
- 脚本用途:数据流处理(1)
|
||||||
|
- 触发事件:设备上报(1)
|
||||||
|
- 脚本内容:解析 hex 数据,输出物模型 JSON
|
||||||
|
|
||||||
|
### 设备配置
|
||||||
|
|
||||||
|
- 设备编号:小程序 clientId 中的 `{设备编号}`
|
||||||
|
- 产品密钥:作为 MQTT password
|
||||||
|
|
||||||
|
## 开发规范
|
||||||
|
|
||||||
|
### 代码规范
|
||||||
|
|
||||||
|
1. **命名规范**
|
||||||
|
- 文件名:小写 + 连字符(`device-card.js`)
|
||||||
|
- 变量名:小驼峰(`deviceId`)
|
||||||
|
- 常量:大写 + 下划线(`SERVICE_UUID`)
|
||||||
|
- 私有方法:下划线前缀(`_handleData()`)
|
||||||
|
|
||||||
|
2. **注释规范**
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* 扫描蓝牙设备
|
||||||
|
* @param {Array<string>} serviceUUIDs - 服务 UUID 列表
|
||||||
|
* @returns {Promise<Array>} 设备列表
|
||||||
|
*/
|
||||||
|
async scanDevices(serviceUUIDs) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **错误处理**
|
||||||
|
- 所有异步操作使用 try-catch
|
||||||
|
- 用户友好错误提示(`wx.showToast`)
|
||||||
|
|
||||||
|
### 包体积控制
|
||||||
|
|
||||||
|
**目标**:主包 < 1.5MB
|
||||||
|
|
||||||
|
**策略**:
|
||||||
|
1. 不使用 UI 框架(原生组件)
|
||||||
|
2. mqtt.js 已压缩并预置在 libs 目录
|
||||||
|
3. 图片使用 CDN 或压缩
|
||||||
|
4. 分包加载(如有需要)
|
||||||
|
|
||||||
|
## 调试与测试
|
||||||
|
|
||||||
|
### 微信开发者工具
|
||||||
|
|
||||||
|
1. **真机调试**:蓝牙功能必须在真机上测试
|
||||||
|
2. **MQTT 调试**:使用 `wxs://` 协议,确保域名配置正确
|
||||||
|
|
||||||
|
### 平台验证
|
||||||
|
|
||||||
|
1. yack 控制台查看设备数据
|
||||||
|
2. 检查 `iot_device_log` 表
|
||||||
|
3. 查看规则脚本日志
|
||||||
|
|
||||||
|
## 部署流程
|
||||||
|
|
||||||
|
1. **微信公众平台配置**
|
||||||
|
- 服务器域名:添加 `socket 合法域名`(wxs://...)
|
||||||
|
- 业务域名:如需 H5 页面
|
||||||
|
|
||||||
|
2. **项目配置**
|
||||||
|
- 修改 `project.config.json` 中的 `appid`
|
||||||
|
- 修改 `env.config.js` 中的 API 和 MQTT 地址
|
||||||
|
- 将 `ENV` 改为 `'production'`
|
||||||
|
|
||||||
|
3. **代码上传**
|
||||||
|
- 微信开发者工具 → 上传代码
|
||||||
|
- 设置版本号、备注
|
||||||
|
|
||||||
|
4. **提交审核**
|
||||||
|
- 填写功能说明
|
||||||
|
- 蓝牙权限说明
|
||||||
|
|
||||||
|
## 扩展路径
|
||||||
|
|
||||||
|
1. **新增 Modbus 设备**
|
||||||
|
- 平台配置 Modbus 参数
|
||||||
|
- 小程序扫描新设备(可能需更新 Service UUID)
|
||||||
|
|
||||||
|
2. **新增私有协议设备**
|
||||||
|
- 平台编写规则脚本
|
||||||
|
- 小程序适配 BLE 特征值
|
||||||
|
|
||||||
|
3. **设备控制(下发指令)**
|
||||||
|
- 订阅 `/function/get` 或 `/property/get`
|
||||||
|
- 通过 `bleService.writeCommand()` 发送
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [微信小程序蓝牙 API](https://developers.weixin.qq.com/miniprogram/dev/api/device/bluetooth/wx.openBluetoothAdapter.html)
|
||||||
|
- [mqtt.js 文档](https://github.com/mqttjs/MQTT.js)
|
||||||
|
- [yack 协议解析](CLAUDE.md#架构说明)
|
||||||
|
- [Modbus 配置表](sql/yack-init.sql 中 `iot_modbus_config`)
|
||||||
9
memory/MEMORY.md
Normal file
9
memory/MEMORY.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Memory Index
|
||||||
|
|
||||||
|
- [Platform integration direction](project_platform_integration_direction.md) — Shared iPaaS/flow orchestration with tenant-specific mappings; DCP one point per device.
|
||||||
|
- [yack-web Node version](project_yack_web_node_version.md) — Use Node 16.20.2 via nvm before yack-web npm commands.
|
||||||
|
- [Vertical calculated model](project_vertical_calc_model.md) — isCalculated=2 accumulates from cached model value; no new state table.
|
||||||
|
- [Permission policy](project_permission_policy.md) — Cross-page read/query APIs use hasAnyPermi with consuming page permissions.
|
||||||
|
- [Homepage terminology](feedback_homepage_terminology.md) — "首页"指 bigScreen/home.vue;"后台管理首页"指 index.vue。
|
||||||
|
- [Documentation output preference](feedback_documentation_output.md) — 只在用户明确要求时输出md文档。
|
||||||
|
- [yack-mini tech stack](yack-mini_tech_stack.md) — 原生微信小程序蓝牙网关,透传 BLE 数据至 FastBee。
|
||||||
27
memory/yack-mini_tech_stack.md
Normal file
27
memory/yack-mini_tech_stack.md
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: yack-mini-tech-stack
|
||||||
|
description: yack-mini 使用原生微信小程序开发(非 UniApp),轻量化蓝牙网关
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
**项目名称**:yack-mini
|
||||||
|
|
||||||
|
**技术栈**:原生微信小程序(WXML/WXSS/JS)
|
||||||
|
|
||||||
|
**定位**:轻量化蓝牙网关,采集 BLE 设备数据透传至 FastBee
|
||||||
|
|
||||||
|
**与 yack-app 区别**:
|
||||||
|
- yack-app:UniApp 全功能移动端(包大)
|
||||||
|
- yack-mini:原生小程序蓝牙网关(包小)
|
||||||
|
|
||||||
|
**核心功能**:
|
||||||
|
1. BLE 设备扫描、连接、数据采集
|
||||||
|
2. MQTT over wxs:// 上报数据
|
||||||
|
3. 透传模式(Modbus 原始帧 / 其他协议 hex 数据)
|
||||||
|
|
||||||
|
**开发 skill**:[[yack-mini-dev]]
|
||||||
|
|
||||||
|
**Why**:用户反馈 yack-app 包体积过大无法发布小程序,故新建轻量化项目
|
||||||
|
|
||||||
|
**How to apply**:开发 yack-mini 时调用 `/yack-mini-dev` skill 获取项目架构和规范
|
||||||
12
yack-mini/.gitignore
vendored
Normal file
12
yack-mini/.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# 微信开发者工具
|
||||||
|
.DS_Store
|
||||||
|
*.mp3
|
||||||
|
*.mp4
|
||||||
|
|
||||||
|
# 依赖目录(如果有使用 npm)
|
||||||
|
node_modules/
|
||||||
|
miniprogram_npm/
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
205
yack-mini/README.md
Normal file
205
yack-mini/README.md
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
# yack-mini
|
||||||
|
|
||||||
|
yack 蓝牙网关微信小程序,采集 BLE 设备数据透传上报至 yack 平台。
|
||||||
|
|
||||||
|
## 项目定位
|
||||||
|
|
||||||
|
- **技术栈**:原生微信小程序
|
||||||
|
- **目标**:轻量化蓝牙网关,透传数据至 yack
|
||||||
|
- **与 yack-app 区别**:yack-app 是全功能移动端(UniApp),yack-mini 是轻量蓝牙网关(原生小程序)
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ BLE ┌──────────────────┐ wxs ┌─────────────────┐
|
||||||
|
│ BLE 设备 │ ←──────→ │ yack-mini │ ←──────→ │ yack 平台 │
|
||||||
|
│ (传感器) │ │ (微信小程序) │ MQTT │ (数据存储) │
|
||||||
|
└─────────────┘ └──────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
yack-mini/
|
||||||
|
├── project.config.json # 项目配置
|
||||||
|
└── miniprogram/ # 小程序源码
|
||||||
|
├── app.js # 应用入口
|
||||||
|
├── app.json # 全局配置
|
||||||
|
├── app.wxss # 全局样式
|
||||||
|
├── env.config.js # 环境配置(API/MQTT 地址)
|
||||||
|
├── sitemap.json # 站点地图
|
||||||
|
│
|
||||||
|
├── pages/ # 页面
|
||||||
|
│ ├── login/ # 登录页
|
||||||
|
│ ├── index/ # 首页(设备列表)
|
||||||
|
│ └── device/
|
||||||
|
│ ├── scan/ # 扫描设备页
|
||||||
|
│ └── detail/ # 设备详情/实时数据页
|
||||||
|
│
|
||||||
|
├── services/ # 核心服务模块
|
||||||
|
│ ├── ble.js # 蓝牙采集模块
|
||||||
|
│ └── mqtt-client.js # MQTT 连接模块
|
||||||
|
│
|
||||||
|
├── utils/ # 工具函数
|
||||||
|
│ ├── hex.js # 十六进制转换
|
||||||
|
│ ├── storage.js # 本地存储
|
||||||
|
│ ├── auth.js # MQTT 凭证获取
|
||||||
|
│ └── request.js # HTTP 请求
|
||||||
|
│
|
||||||
|
├── components/ # 公共组件
|
||||||
|
│ └── device-card/ # 设备卡片组件
|
||||||
|
│
|
||||||
|
├── libs/ # 第三方库
|
||||||
|
│ └── mqtt.min.js # mqtt.js (4.3.7, 支持微信小程序)
|
||||||
|
│
|
||||||
|
└── assets/ # 静态资源
|
||||||
|
└── images/ # 图片
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 导入项目
|
||||||
|
|
||||||
|
1. 下载并安装 [微信开发者工具](https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html)
|
||||||
|
2. 打开微信开发者工具,选择「导入项目」
|
||||||
|
3. 项目目录选择 `yack-mini`
|
||||||
|
4. AppID 使用测试号或填写正式 AppID
|
||||||
|
|
||||||
|
### 2. 配置环境
|
||||||
|
|
||||||
|
编辑 `miniprogram/env.config.js`,配置 yack 平台地址:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 开发环境
|
||||||
|
const DEV_CONFIG = {
|
||||||
|
baseUrl: 'http://localhost:8080',
|
||||||
|
mqttServer: 'wxs://localhost:8084/mqtt',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生产环境
|
||||||
|
const PROD_CONFIG = {
|
||||||
|
baseUrl: 'https://your-domain.com/prod-api',
|
||||||
|
mqttServer: 'wxs://your-domain.com/mqtt',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 替换图片资源
|
||||||
|
|
||||||
|
`miniprogram/assets/images/` 目录下的图片为占位图,请替换为实际图片:
|
||||||
|
|
||||||
|
- `logo.png` - 应用 Logo
|
||||||
|
- `device.png` / `device-active.png` - TabBar 图标
|
||||||
|
- `device-empty.png` - 空状态图
|
||||||
|
- `ble-scan.png` - 扫描页图标
|
||||||
|
- `default-avatar.png` - 默认头像
|
||||||
|
|
||||||
|
### 4. 真机调试
|
||||||
|
|
||||||
|
蓝牙功能必须在真机上测试:
|
||||||
|
1. 点击「真机调试」
|
||||||
|
2. 使用手机扫码预览
|
||||||
|
3. 确保手机蓝牙已开启
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
### 1. 登录认证
|
||||||
|
|
||||||
|
- yack 账号密码登录
|
||||||
|
- 获取 Token 和用户信息(含 userId)
|
||||||
|
- 登录态持久化存储
|
||||||
|
|
||||||
|
### 2. BLE 设备扫描
|
||||||
|
|
||||||
|
- 初始化蓝牙适配器
|
||||||
|
- 扫描周边 BLE 设备
|
||||||
|
- 支持按 Service UUID 过滤
|
||||||
|
|
||||||
|
### 3. 设备配置
|
||||||
|
|
||||||
|
配置设备参数:
|
||||||
|
- 设备名称
|
||||||
|
- 设备编号(SN)
|
||||||
|
- 产品 ID
|
||||||
|
- 产品密钥(用于 MQTT 认证)
|
||||||
|
- BLE 服务 UUID(可选)
|
||||||
|
|
||||||
|
### 4. 实时数据采集
|
||||||
|
|
||||||
|
- BLE 连接与数据监听
|
||||||
|
- 十六进制数据展示
|
||||||
|
- 自动/手动上报到 yack
|
||||||
|
|
||||||
|
### 5. MQTT 数据上报
|
||||||
|
|
||||||
|
- 连接 yack MQTT Broker(`wxs://` 协议)
|
||||||
|
- 发布属性数据到 `/{productId}/{sn}/property/post`
|
||||||
|
- 支持订阅下发指令
|
||||||
|
|
||||||
|
## MQTT 认证说明
|
||||||
|
|
||||||
|
设备连接 yack MQTT Broker 的凭证格式:
|
||||||
|
|
||||||
|
| 参数 | 格式 | 示例 |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| clientId | `S&{设备编号}&{产品ID}&{用户ID}` | `S&BLE001&1&100` |
|
||||||
|
| username | `{设备编号}` | `BLE001` |
|
||||||
|
| password | `{产品密钥}` | `abc123` |
|
||||||
|
|
||||||
|
## 数据上报格式
|
||||||
|
|
||||||
|
BLE 设备数据以十六进制字符串上报:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Modbus 帧示例
|
||||||
|
mqttClient.publish('/1/BLE001/property/post', '010304009A005B8B7C');
|
||||||
|
|
||||||
|
// 私有协议示例
|
||||||
|
mqttClient.publish('/1/BLE002/property/post', '009A005B');
|
||||||
|
```
|
||||||
|
|
||||||
|
yack 平台通过规则脚本或 Modbus 配置解析数据。
|
||||||
|
|
||||||
|
## 微信小程序配置
|
||||||
|
|
||||||
|
### 服务器域名
|
||||||
|
|
||||||
|
在微信公众平台配置:
|
||||||
|
|
||||||
|
- **socket 合法域名**:`wxs://your-domain.com`
|
||||||
|
- **request 合法域名**:`https://your-domain.com`
|
||||||
|
|
||||||
|
### 权限说明
|
||||||
|
|
||||||
|
- `scope.bluetooth`:蓝牙权限(部分设备需要用户授权)
|
||||||
|
- `scope.userLocation`:部分 Android 设备扫描 BLE 需要位置权限
|
||||||
|
|
||||||
|
## 开发调试
|
||||||
|
|
||||||
|
### BLE 调试
|
||||||
|
|
||||||
|
1. 使用真机调试
|
||||||
|
2. 查看 Console 日志,前缀 `[BLE]`
|
||||||
|
3. 使用 nRF Connect 等工具验证 BLE 设备
|
||||||
|
|
||||||
|
### MQTT 调试
|
||||||
|
|
||||||
|
1. 查看 Console 日志,前缀 `[MQTT]`
|
||||||
|
2. 使用 MQTT 客户端工具验证连接
|
||||||
|
3. 确保 `wxs://` 域名已在微信公众平台配置
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **蓝牙权限**:iOS 无需额外授权,Android 可能需要位置权限
|
||||||
|
2. **MQTT 协议**:微信小程序必须使用 `wxs://` 协议(端口 8084)
|
||||||
|
3. **数据格式**:BLE 数据需转换为十六进制字符串上报
|
||||||
|
4. **包体积**:主包控制在 1.5MB 以内
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [微信小程序蓝牙 API](https://developers.weixin.qq.com/miniprogram/dev/api/device/bluetooth/wx.openBluetoothAdapter.html)
|
||||||
|
- [mqtt.js 文档](https://github.com/mqttjs/MQTT.js)
|
||||||
|
- [yack 文档](../CLAUDE.md)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
88
yack-mini/miniprogram/app.js
Normal file
88
yack-mini/miniprogram/app.js
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* 应用入口
|
||||||
|
*/
|
||||||
|
const envConfig = require('./env.config.js');
|
||||||
|
const storage = require('./utils/storage.js');
|
||||||
|
|
||||||
|
App({
|
||||||
|
globalData: {
|
||||||
|
// 用户信息
|
||||||
|
userInfo: null,
|
||||||
|
userId: null,
|
||||||
|
token: null,
|
||||||
|
// 环境配置
|
||||||
|
envConfig: envConfig,
|
||||||
|
// 当前连接的设备
|
||||||
|
currentDevice: null,
|
||||||
|
// MQTT 客户端
|
||||||
|
mqttClient: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLaunch() {
|
||||||
|
console.log('[App] onLaunch');
|
||||||
|
// 检查登录态
|
||||||
|
this.checkLoginStatus();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查登录状态
|
||||||
|
* 登录态只依赖 userId(登录一次即可,业务流程不使用 token)
|
||||||
|
*/
|
||||||
|
checkLoginStatus() {
|
||||||
|
const userInfo = storage.getUserInfo();
|
||||||
|
if (userInfo && userInfo.userId) {
|
||||||
|
this.globalData.userInfo = userInfo;
|
||||||
|
this.globalData.userId = userInfo.userId;
|
||||||
|
// token 可选保存(仅登录流程使用,业务不依赖)
|
||||||
|
this.globalData.token = storage.getToken() || null;
|
||||||
|
console.log('[App] 已登录, userId:', userInfo.userId);
|
||||||
|
} else {
|
||||||
|
console.log('[App] 未登录');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置登录信息
|
||||||
|
* @param {Object} data - 登录返回数据 { token, user }
|
||||||
|
*/
|
||||||
|
setLoginInfo(data) {
|
||||||
|
const userInfo = {
|
||||||
|
userId: data.userId,
|
||||||
|
userName: data.userName,
|
||||||
|
phonenumber: data.phonenumber,
|
||||||
|
avatar: data.avatar,
|
||||||
|
};
|
||||||
|
this.globalData.token = data.token;
|
||||||
|
this.globalData.userInfo = userInfo;
|
||||||
|
this.globalData.userId = data.userId;
|
||||||
|
|
||||||
|
// 持久化存储
|
||||||
|
storage.setToken(data.token);
|
||||||
|
storage.setUserInfo(userInfo);
|
||||||
|
|
||||||
|
console.log('[App] 登录成功, userId:', data.userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除登录信息
|
||||||
|
*/
|
||||||
|
clearLoginInfo() {
|
||||||
|
this.globalData.token = null;
|
||||||
|
this.globalData.userInfo = null;
|
||||||
|
this.globalData.userId = null;
|
||||||
|
|
||||||
|
storage.removeToken();
|
||||||
|
storage.removeUserInfo();
|
||||||
|
|
||||||
|
console.log('[App] 已退出登录');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已登录
|
||||||
|
* 只检查 userId,token 过期不影响登录态(登录一次即可)
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isLoggedIn() {
|
||||||
|
return !!this.globalData.userId;
|
||||||
|
},
|
||||||
|
});
|
||||||
32
yack-mini/miniprogram/app.json
Normal file
32
yack-mini/miniprogram/app.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
"pages/login/login",
|
||||||
|
"pages/index/index",
|
||||||
|
"pages/device/scan/scan",
|
||||||
|
"pages/device/detail/detail"
|
||||||
|
],
|
||||||
|
"window": {
|
||||||
|
"navigationBarTitleText": "岩安测控网关",
|
||||||
|
"navigationBarBackgroundColor": "#1890ff",
|
||||||
|
"navigationBarTextStyle": "white",
|
||||||
|
"backgroundColor": "#f5f5f5",
|
||||||
|
"backgroundTextStyle": "dark"
|
||||||
|
},
|
||||||
|
"tabBar": {
|
||||||
|
"color": "#999999",
|
||||||
|
"selectedColor": "#1890ff",
|
||||||
|
"backgroundColor": "#ffffff",
|
||||||
|
"borderStyle": "white",
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"pagePath": "pages/index/index",
|
||||||
|
"text": "设备",
|
||||||
|
"iconPath": "assets/images/device.png",
|
||||||
|
"selectedIconPath": "assets/images/device-active.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"style": "v2",
|
||||||
|
"sitemapLocation": "sitemap.json",
|
||||||
|
"lazyCodeLoading": "requiredComponents"
|
||||||
|
}
|
||||||
147
yack-mini/miniprogram/app.wxss
Normal file
147
yack-mini/miniprogram/app.wxss
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* 全局样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 页面基础样式 */
|
||||||
|
page {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通用容器 */
|
||||||
|
.container {
|
||||||
|
padding: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片样式 */
|
||||||
|
.card {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮样式 */
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
padding: 20rpx 40rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
background-color: #096dd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-default {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
border: 1rpx solid #d9d9d9;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
padding: 20rpx 40rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-default:active {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #ff4d4f;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
padding: 20rpx 40rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入框样式 */
|
||||||
|
.input {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表项 */
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题 */
|
||||||
|
.title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 副标题 */
|
||||||
|
.subtitle {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态标签 */
|
||||||
|
.status-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.online {
|
||||||
|
background-color: #f6ffed;
|
||||||
|
color: #52c41a;
|
||||||
|
border: 1rpx solid #b7eb8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.offline {
|
||||||
|
background-color: #fff7e6;
|
||||||
|
color: #fa8c16;
|
||||||
|
border: 1rpx solid #ffd591;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100rpx 40rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载中 */
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 40rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
BIN
yack-mini/miniprogram/assets/images/ble-scan.png
Normal file
BIN
yack-mini/miniprogram/assets/images/ble-scan.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
yack-mini/miniprogram/assets/images/default-avatar.png
Normal file
BIN
yack-mini/miniprogram/assets/images/default-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
yack-mini/miniprogram/assets/images/device-active.png
Normal file
BIN
yack-mini/miniprogram/assets/images/device-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
yack-mini/miniprogram/assets/images/device-empty.png
Normal file
BIN
yack-mini/miniprogram/assets/images/device-empty.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
yack-mini/miniprogram/assets/images/device.png
Normal file
BIN
yack-mini/miniprogram/assets/images/device.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
yack-mini/miniprogram/assets/images/logo.png
Normal file
BIN
yack-mini/miniprogram/assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
45
yack-mini/miniprogram/components/device-card/device-card.js
Normal file
45
yack-mini/miniprogram/components/device-card/device-card.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* 设备卡片组件
|
||||||
|
*/
|
||||||
|
Component({
|
||||||
|
properties: {
|
||||||
|
// 设备对象 { serialNumber, name, productId, productName, enabled, connected }
|
||||||
|
device: {
|
||||||
|
type: Object,
|
||||||
|
value: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* 点击卡片
|
||||||
|
*/
|
||||||
|
handleTap() {
|
||||||
|
this.triggerEvent('tap', { device: this.data.device });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阻止事件冒泡
|
||||||
|
*/
|
||||||
|
preventBubble() {},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启停切换
|
||||||
|
*/
|
||||||
|
handleSwitch(e) {
|
||||||
|
this.triggerEvent('switch', {
|
||||||
|
serialNumber: this.data.device.serialNumber,
|
||||||
|
enabled: e.detail.value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除设备
|
||||||
|
*/
|
||||||
|
handleDelete() {
|
||||||
|
this.triggerEvent('delete', {
|
||||||
|
serialNumber: this.data.device.serialNumber,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"component": true,
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
<!-- 设备卡片组件 -->
|
||||||
|
<view class="device-card" bindtap="handleTap">
|
||||||
|
<!-- 设备信息 -->
|
||||||
|
<view class="device-main">
|
||||||
|
<view class="device-header">
|
||||||
|
<text class="device-name">{{device.name || device.deviceName || '未命名设备'}}</text>
|
||||||
|
<view class="status-tag {{device.connected ? 'online' : 'offline'}}">
|
||||||
|
{{device.connected ? '已连接' : '未连接'}}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="device-info">
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">编号:</text>
|
||||||
|
<text class="info-value">{{device.serialNumber}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">产品:</text>
|
||||||
|
<text class="info-value">{{device.productName || device.productId}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 操作区 -->
|
||||||
|
<view class="device-actions">
|
||||||
|
<!-- 启停开关 -->
|
||||||
|
<switch
|
||||||
|
class="device-switch"
|
||||||
|
checked="{{device.enabled}}"
|
||||||
|
color="#1890ff"
|
||||||
|
catchtap="preventBubble"
|
||||||
|
bindchange="handleSwitch"
|
||||||
|
/>
|
||||||
|
<!-- 删除按钮 -->
|
||||||
|
<view class="delete-btn" catchtap="handleDelete">删除</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
/* 设备卡片组件样式 */
|
||||||
|
.device-card {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.online {
|
||||||
|
background-color: #f6ffed;
|
||||||
|
color: #52c41a;
|
||||||
|
border: 1rpx solid #b7eb8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.offline {
|
||||||
|
background-color: #fff7e6;
|
||||||
|
color: #fa8c16;
|
||||||
|
border: 1rpx solid #ffd591;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
width: 100rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-switch {
|
||||||
|
transform: scale(0.8);
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #ff4d4f;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
}
|
||||||
21
yack-mini/miniprogram/env.config.js
Normal file
21
yack-mini/miniprogram/env.config.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 环境配置
|
||||||
|
* yack 平台 API 和 MQTT 服务地址
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 开发环境
|
||||||
|
const DEV_CONFIG = {
|
||||||
|
baseUrl: 'http://localhost:8080',
|
||||||
|
mqttServer: 'wxs://localhost:8084/mqtt',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生产环境
|
||||||
|
const PROD_CONFIG = {
|
||||||
|
baseUrl: 'https://ycdat.cn/prod-api',
|
||||||
|
mqttServer: 'wxs://ycdat.cn/mqtt',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当前环境:development | production
|
||||||
|
const ENV = 'development';
|
||||||
|
|
||||||
|
module.exports = ENV === 'development' ? DEV_CONFIG : PROD_CONFIG;
|
||||||
1
yack-mini/miniprogram/libs/mqtt.min.js
vendored
Normal file
1
yack-mini/miniprogram/libs/mqtt.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
354
yack-mini/miniprogram/pages/device/detail/detail.js
Normal file
354
yack-mini/miniprogram/pages/device/detail/detail.js
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
/**
|
||||||
|
* 设备详情页 - 实时数据
|
||||||
|
*/
|
||||||
|
const ble = require('../../../services/ble.js');
|
||||||
|
const mqtt = require('../../../services/mqtt-client.js');
|
||||||
|
const storage = require('../../../utils/storage.js');
|
||||||
|
const auth = require('../../../utils/auth.js');
|
||||||
|
|
||||||
|
const app = getApp();
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
device: null,
|
||||||
|
bleConnected: false,
|
||||||
|
mqttConnected: false,
|
||||||
|
connecting: false,
|
||||||
|
mqttConnecting: false,
|
||||||
|
|
||||||
|
// 数据列表
|
||||||
|
dataList: [],
|
||||||
|
maxDataCount: 50,
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
stats: {
|
||||||
|
received: 0,
|
||||||
|
uploaded: 0,
|
||||||
|
failed: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad(options) {
|
||||||
|
const sn = options.sn;
|
||||||
|
if (!sn) {
|
||||||
|
wx.showToast({ title: '设备信息缺失', icon: 'none' });
|
||||||
|
setTimeout(() => wx.navigateBack(), 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从全局或存储获取设备信息
|
||||||
|
let device = app.globalData.currentDevice;
|
||||||
|
if (!device || device.serialNumber !== sn) {
|
||||||
|
device = storage.getDevice(sn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
wx.showToast({ title: '设备不存在', icon: 'none' });
|
||||||
|
setTimeout(() => wx.navigateBack(), 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData({ device });
|
||||||
|
|
||||||
|
// 监听 BLE 状态变化
|
||||||
|
ble.bleService.onStateChange((state) => {
|
||||||
|
const connected = state === ble.BLE_STATE.READY || state === ble.BLE_STATE.CONNECTED;
|
||||||
|
this.setData({ bleConnected: connected });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听 BLE 数据
|
||||||
|
ble.bleService.onData((data) => {
|
||||||
|
this._handleBleData(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听 MQTT 状态变化
|
||||||
|
mqtt.mqttClient.onStateChange((state) => {
|
||||||
|
const connected = state === mqtt.MQTT_STATE.CONNECTED;
|
||||||
|
this.setData({ mqttConnected: connected });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload() {
|
||||||
|
// 清除回调,防止页面销毁后 setData 报错
|
||||||
|
ble.bleService.clearCallbacks();
|
||||||
|
mqtt.mqttClient.clearCallbacks();
|
||||||
|
|
||||||
|
// 断开连接
|
||||||
|
ble.bleService.disconnect();
|
||||||
|
mqtt.mqttClient.disconnect();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 BLE 数据
|
||||||
|
*/
|
||||||
|
_handleBleData(data) {
|
||||||
|
const now = new Date();
|
||||||
|
const time = this._formatTime(now);
|
||||||
|
|
||||||
|
// 使用唯一 ID 标识数据项(防止重复数据更新错乱)
|
||||||
|
const dataItem = {
|
||||||
|
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
hex: data.hex,
|
||||||
|
time: time,
|
||||||
|
uploaded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let dataList = this.data.dataList;
|
||||||
|
dataList.unshift(dataItem);
|
||||||
|
|
||||||
|
// 限制数量
|
||||||
|
if (dataList.length > this.data.maxDataCount) {
|
||||||
|
dataList = dataList.slice(0, this.data.maxDataCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
dataList,
|
||||||
|
'stats.received': this.data.stats.received + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果 MQTT 已连接且设备已启用,自动上报
|
||||||
|
if (this.data.mqttConnected && this.data.device.enabled) {
|
||||||
|
this._uploadData(data.hex, dataItem.id);
|
||||||
|
} else {
|
||||||
|
// MQTT 未连接,缓存数据(离线缓存)
|
||||||
|
storage.cachePendingData({
|
||||||
|
deviceId: this.data.device.serialNumber,
|
||||||
|
productId: this.data.device.productId,
|
||||||
|
hex: data.hex,
|
||||||
|
dataId: dataItem.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上报数据到 yack
|
||||||
|
* @param {string} hexData - 十六进制数据
|
||||||
|
* @param {string} [dataId] - 数据项 ID(用于更新列表状态)
|
||||||
|
* @throws 上报失败时抛出异常
|
||||||
|
*/
|
||||||
|
async _uploadData(hexData, dataId) {
|
||||||
|
const device = this.data.device;
|
||||||
|
if (!device) return;
|
||||||
|
|
||||||
|
const topic = auth.buildPropertyPostTopic(device.productId, device.serialNumber);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mqtt.mqttClient.publish(topic, hexData);
|
||||||
|
console.log('[Detail] 上报成功:', hexData);
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
this.setData({
|
||||||
|
'stats.uploaded': this.data.stats.uploaded + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新 dataList 中的状态(按唯一 ID 查找)
|
||||||
|
if (dataId) {
|
||||||
|
const dataList = this.data.dataList;
|
||||||
|
const index = dataList.findIndex(d => d.id === dataId);
|
||||||
|
if (index >= 0) {
|
||||||
|
this.setData({
|
||||||
|
[`dataList[${index}].uploaded`]: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Detail] 上报失败:', err);
|
||||||
|
this.setData({
|
||||||
|
'stats.failed': this.data.stats.failed + 1,
|
||||||
|
});
|
||||||
|
// 抛出异常,供调用方统计失败
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接/断开 BLE
|
||||||
|
*/
|
||||||
|
async handleConnect() {
|
||||||
|
const { device, bleConnected } = this.data;
|
||||||
|
if (!device) return;
|
||||||
|
|
||||||
|
if (bleConnected) {
|
||||||
|
// 断开连接
|
||||||
|
await ble.bleService.disconnect();
|
||||||
|
this.setData({ bleConnected: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData({ connecting: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 初始化蓝牙
|
||||||
|
await ble.bleService.initBluetooth();
|
||||||
|
// 连接设备
|
||||||
|
await ble.bleService.connectDevice(device.deviceId, device.serviceUUID);
|
||||||
|
this.setData({ bleConnected: true });
|
||||||
|
wx.showToast({ title: 'BLE 已连接', icon: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Detail] BLE 连接失败:', err);
|
||||||
|
wx.showToast({
|
||||||
|
title: err.message || 'BLE 连接失败',
|
||||||
|
icon: 'none',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.setData({ connecting: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接/断开 MQTT
|
||||||
|
*/
|
||||||
|
async handleMqttConnect() {
|
||||||
|
const { device, mqttConnected } = this.data;
|
||||||
|
if (!device) return;
|
||||||
|
|
||||||
|
if (mqttConnected) {
|
||||||
|
// 断开连接
|
||||||
|
await mqtt.mqttClient.disconnect();
|
||||||
|
this.setData({ mqttConnected: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData({ mqttConnecting: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取 MQTT 凭证
|
||||||
|
const credentials = auth.getMqttCredentialsFromDevice(device);
|
||||||
|
if (!credentials) {
|
||||||
|
throw new Error('获取 MQTT 凭证失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接 MQTT
|
||||||
|
await mqtt.mqttClient.connect(credentials);
|
||||||
|
this.setData({ mqttConnected: true });
|
||||||
|
wx.showToast({ title: 'MQTT 已连接', icon: 'success' });
|
||||||
|
|
||||||
|
// 上传缓存的离线数据
|
||||||
|
this._uploadPendingData();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Detail] MQTT 连接失败:', err);
|
||||||
|
wx.showToast({
|
||||||
|
title: err.message || 'MQTT 连接失败',
|
||||||
|
icon: 'none',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.setData({ mqttConnecting: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传缓存的离线数据
|
||||||
|
*/
|
||||||
|
async _uploadPendingData() {
|
||||||
|
const pendingList = storage.getPendingData();
|
||||||
|
if (pendingList.length === 0) return;
|
||||||
|
|
||||||
|
console.log('[Detail] 开始上传离线缓存数据:', pendingList.length, '条');
|
||||||
|
const device = this.data.device;
|
||||||
|
|
||||||
|
let uploaded = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const item of pendingList) {
|
||||||
|
// 只上传当前设备的数据
|
||||||
|
if (item.deviceId === device.serialNumber) {
|
||||||
|
try {
|
||||||
|
const topic = auth.buildPropertyPostTopic(device.productId, device.serialNumber);
|
||||||
|
await mqtt.mqttClient.publish(topic, item.hex);
|
||||||
|
uploaded++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Detail] 离线数据上传失败:', item.hex, err);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除已处理的缓存数据
|
||||||
|
storage.clearPendingData();
|
||||||
|
|
||||||
|
if (uploaded > 0 || failed > 0) {
|
||||||
|
console.log('[Detail] 离线数据上传完成:', uploaded, '成功,', failed, '失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上报单条数据
|
||||||
|
*/
|
||||||
|
async handleUploadSingle(e) {
|
||||||
|
const index = e.currentTarget.dataset.index;
|
||||||
|
const data = this.data.dataList[index];
|
||||||
|
if (!data || data.uploaded) return;
|
||||||
|
|
||||||
|
if (!this.data.mqttConnected) {
|
||||||
|
wx.showToast({ title: 'MQTT 未连接', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._uploadData(data.hex, data.id);
|
||||||
|
} catch (err) {
|
||||||
|
wx.showToast({ title: err.message || '上报失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全部上报
|
||||||
|
*/
|
||||||
|
async handleUploadAll() {
|
||||||
|
const { dataList, mqttConnected } = this.data;
|
||||||
|
if (!mqttConnected || dataList.length === 0) return;
|
||||||
|
|
||||||
|
wx.showLoading({ title: '上报中...', mask: true });
|
||||||
|
|
||||||
|
let uploaded = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const data of dataList) {
|
||||||
|
if (!data.uploaded) {
|
||||||
|
try {
|
||||||
|
await this._uploadData(data.hex, data.id);
|
||||||
|
uploaded++;
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wx.hideLoading();
|
||||||
|
|
||||||
|
wx.showToast({
|
||||||
|
title: `上报完成: ${uploaded} 成功, ${failed} 失败`,
|
||||||
|
icon: 'none',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空数据
|
||||||
|
*/
|
||||||
|
handleClearData() {
|
||||||
|
wx.showModal({
|
||||||
|
title: '清空数据',
|
||||||
|
content: '确定清空所有接收到的数据吗?',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
this.setData({
|
||||||
|
dataList: [],
|
||||||
|
stats: { received: 0, uploaded: 0, failed: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化时间
|
||||||
|
*/
|
||||||
|
_formatTime(date) {
|
||||||
|
const h = date.getHours().toString().padStart(2, '0');
|
||||||
|
const m = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
const s = date.getSeconds().toString().padStart(2, '0');
|
||||||
|
return `${h}:${m}:${s}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
5
yack-mini/miniprogram/pages/device/detail/detail.json
Normal file
5
yack-mini/miniprogram/pages/device/detail/detail.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "设备详情",
|
||||||
|
"enablePullDownRefresh": false,
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
91
yack-mini/miniprogram/pages/device/detail/detail.wxml
Normal file
91
yack-mini/miniprogram/pages/device/detail/detail.wxml
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
<!-- 设备详情页 -->
|
||||||
|
<view class="container">
|
||||||
|
<!-- 设备信息 -->
|
||||||
|
<view class="device-info card">
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">设备名称</text>
|
||||||
|
<text class="info-value">{{device.name || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">设备编号</text>
|
||||||
|
<text class="info-value">{{device.serialNumber || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">产品ID</text>
|
||||||
|
<text class="info-value">{{device.productId || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">BLE 地址</text>
|
||||||
|
<text class="info-value">{{device.deviceId || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">BLE 状态</text>
|
||||||
|
<view class="status-tag {{bleConnected ? 'online' : 'offline'}}">
|
||||||
|
{{bleConnected ? '已连接' : '未连接'}}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">MQTT 状态</text>
|
||||||
|
<view class="status-tag {{mqttConnected ? 'online' : 'offline'}}">
|
||||||
|
{{mqttConnected ? '已连接' : '未连接'}}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 实时数据 -->
|
||||||
|
<view class="data-section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">实时数据</text>
|
||||||
|
<text class="clear-btn" bindtap="handleClearData">清空</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="data-list card" wx:if="{{dataList.length > 0}}">
|
||||||
|
<view class="data-item" wx:for="{{dataList}}" wx:key="id">
|
||||||
|
<view class="data-time">{{item.time}}</view>
|
||||||
|
<view class="data-hex">{{item.hex}}</view>
|
||||||
|
<view class="data-action" wx:if="{{item.uploaded}}">
|
||||||
|
<text class="uploaded">已上报</text>
|
||||||
|
</view>
|
||||||
|
<view class="data-action" wx:else>
|
||||||
|
<text class="upload-btn" bindtap="handleUploadSingle" data-index="{{index}}">上报</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="empty" wx:else>
|
||||||
|
<text class="empty-text">暂无数据</text>
|
||||||
|
<text class="empty-hint">连接 BLE 设备后,数据将在此显示</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 统计信息 -->
|
||||||
|
<view class="stats-section card">
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-value">{{stats.received}}</text>
|
||||||
|
<text class="stat-label">接收数据</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-value">{{stats.uploaded}}</text>
|
||||||
|
<text class="stat-label">已上报</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-value">{{stats.failed}}</text>
|
||||||
|
<text class="stat-label">失败</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<view class="action-group">
|
||||||
|
<button class="btn-connect" bindtap="handleConnect" loading="{{connecting}}" disabled="{{connecting}}">
|
||||||
|
{{bleConnected ? '断开 BLE' : '连接 BLE'}}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn-mqtt" bindtap="handleMqttConnect" loading="{{mqttConnecting}}" disabled="{{mqttConnecting}}">
|
||||||
|
{{mqttConnected ? '断开 MQTT' : '连接 MQTT'}}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn-upload" bindtap="handleUploadAll" disabled="{{!mqttConnected || dataList.length === 0}}">
|
||||||
|
全部上报
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
175
yack-mini/miniprogram/pages/device/detail/detail.wxss
Normal file
175
yack-mini/miniprogram/pages/device/detail/detail.wxss
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
/* 设备详情页样式 */
|
||||||
|
.device-info {
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-section {
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-list {
|
||||||
|
max-height: 600rpx;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-time {
|
||||||
|
width: 140rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-hex {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #333;
|
||||||
|
font-family: monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-action {
|
||||||
|
width: 100rpx;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploaded {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #bbb;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-connect,
|
||||||
|
.btn-mqtt,
|
||||||
|
.btn-upload {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
line-height: 80rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-connect {
|
||||||
|
background-color: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-connect[disabled] {
|
||||||
|
background-color: #69c0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mqtt {
|
||||||
|
background-color: #52c41a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mqtt[disabled] {
|
||||||
|
background-color: #95de64;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload {
|
||||||
|
background-color: #722ed1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload[disabled] {
|
||||||
|
background-color: #b37feb;
|
||||||
|
}
|
||||||
220
yack-mini/miniprogram/pages/device/scan/scan.js
Normal file
220
yack-mini/miniprogram/pages/device/scan/scan.js
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* 扫描设备页面
|
||||||
|
*/
|
||||||
|
const ble = require('../../../services/ble.js');
|
||||||
|
const storage = require('../../../utils/storage.js');
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
scanning: false,
|
||||||
|
scannedDevices: [],
|
||||||
|
statusText: '准备扫描',
|
||||||
|
statusHint: '点击下方按钮开始扫描 BLE 设备',
|
||||||
|
|
||||||
|
// 配置弹窗
|
||||||
|
showConfigModal: false,
|
||||||
|
configuring: false,
|
||||||
|
selectedDevice: null,
|
||||||
|
configForm: {
|
||||||
|
name: '',
|
||||||
|
serialNumber: '',
|
||||||
|
productId: '',
|
||||||
|
productSecret: '',
|
||||||
|
serviceUUID: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
// 初始化蓝牙
|
||||||
|
this._initBluetooth();
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload() {
|
||||||
|
// 停止扫描
|
||||||
|
if (this.data.scanning) {
|
||||||
|
ble.bleService.stopScan();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化蓝牙
|
||||||
|
*/
|
||||||
|
async _initBluetooth() {
|
||||||
|
try {
|
||||||
|
await ble.bleService.initBluetooth();
|
||||||
|
this.setData({
|
||||||
|
statusText: '蓝牙已就绪',
|
||||||
|
statusHint: '点击下方按钮开始扫描 BLE 设备',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Scan] 初始化蓝牙失败:', err);
|
||||||
|
this.setData({
|
||||||
|
statusText: '蓝牙初始化失败',
|
||||||
|
statusHint: err.message || '请确保已开启蓝牙',
|
||||||
|
});
|
||||||
|
wx.showModal({
|
||||||
|
title: '蓝牙错误',
|
||||||
|
content: err.message || '请确保已开启蓝牙权限',
|
||||||
|
showCancel: false,
|
||||||
|
success: () => {
|
||||||
|
wx.navigateBack();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始扫描
|
||||||
|
*/
|
||||||
|
async handleScan() {
|
||||||
|
if (this.data.scanning) return;
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
scanning: true,
|
||||||
|
scannedDevices: [],
|
||||||
|
statusText: '正在扫描...',
|
||||||
|
statusHint: '请确保设备已开机且在附近',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const devices = await ble.bleService.scanDevices();
|
||||||
|
this.setData({
|
||||||
|
scanning: false,
|
||||||
|
scannedDevices: devices,
|
||||||
|
statusText: devices.length > 0 ? `发现 ${devices.length} 个设备` : '扫描完成',
|
||||||
|
statusHint: devices.length > 0 ? '点击设备进行连接配置' : '未发现设备,请确保设备已开机',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Scan] 扫描失败:', err);
|
||||||
|
this.setData({
|
||||||
|
scanning: false,
|
||||||
|
statusText: '扫描失败',
|
||||||
|
statusHint: err.message || '请重试',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止扫描
|
||||||
|
*/
|
||||||
|
async handleStopScan() {
|
||||||
|
await ble.bleService.stopScan();
|
||||||
|
this.setData({
|
||||||
|
scanning: false,
|
||||||
|
statusText: '已停止扫描',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择设备 - 显示配置弹窗
|
||||||
|
*/
|
||||||
|
handleSelectDevice(e) {
|
||||||
|
const index = e.currentTarget.dataset.index;
|
||||||
|
const device = this.data.scannedDevices[index];
|
||||||
|
if (!device) return;
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
selectedDevice: device,
|
||||||
|
showConfigModal: true,
|
||||||
|
configForm: {
|
||||||
|
name: device.name || '',
|
||||||
|
serialNumber: '',
|
||||||
|
productId: '',
|
||||||
|
productSecret: '',
|
||||||
|
serviceUUID: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置输入
|
||||||
|
*/
|
||||||
|
onConfigInput(e) {
|
||||||
|
const field = e.currentTarget.dataset.field;
|
||||||
|
const value = e.detail.value;
|
||||||
|
this.setData({
|
||||||
|
[`configForm.${field}`]: value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消配置
|
||||||
|
*/
|
||||||
|
handleCancelConfig() {
|
||||||
|
this.setData({
|
||||||
|
showConfigModal: false,
|
||||||
|
selectedDevice: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认配置 - 连接设备并保存
|
||||||
|
*/
|
||||||
|
async handleConfirmConfig() {
|
||||||
|
const { selectedDevice, configForm } = this.data;
|
||||||
|
|
||||||
|
// 验证配置
|
||||||
|
if (!configForm.serialNumber) {
|
||||||
|
wx.showToast({ title: '请输入设备编号', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!configForm.productId) {
|
||||||
|
wx.showToast({ title: '请输入产品ID', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!configForm.productSecret) {
|
||||||
|
wx.showToast({ title: '请输入产品密钥', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData({ configuring: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 连接设备
|
||||||
|
wx.showLoading({ title: '正在连接...', mask: true });
|
||||||
|
|
||||||
|
const serviceId = configForm.serviceUUID || null;
|
||||||
|
await ble.bleService.connectDevice(selectedDevice.deviceId, serviceId);
|
||||||
|
|
||||||
|
wx.hideLoading();
|
||||||
|
|
||||||
|
// 保存设备配置
|
||||||
|
const deviceConfig = {
|
||||||
|
deviceId: selectedDevice.deviceId,
|
||||||
|
serialNumber: configForm.serialNumber,
|
||||||
|
name: configForm.name || selectedDevice.name || configForm.serialNumber,
|
||||||
|
productId: parseInt(configForm.productId),
|
||||||
|
productSecret: configForm.productSecret,
|
||||||
|
serviceUUID: configForm.serviceUUID,
|
||||||
|
bleName: selectedDevice.name,
|
||||||
|
RSSI: selectedDevice.RSSI,
|
||||||
|
enabled: true,
|
||||||
|
connected: true,
|
||||||
|
createTime: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
storage.addDevice(deviceConfig);
|
||||||
|
|
||||||
|
wx.showToast({ title: '保存成功', icon: 'success' });
|
||||||
|
|
||||||
|
// 断开 BLE 连接(让详情页重新连接,防止连接泄漏)
|
||||||
|
await ble.bleService.disconnect();
|
||||||
|
|
||||||
|
// 延迟返回
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setData({ showConfigModal: false });
|
||||||
|
wx.navigateBack();
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
wx.hideLoading();
|
||||||
|
console.error('[Scan] 连接失败:', err);
|
||||||
|
wx.showToast({
|
||||||
|
title: err.message || '连接失败',
|
||||||
|
icon: 'none',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.setData({ configuring: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
4
yack-mini/miniprogram/pages/device/scan/scan.json
Normal file
4
yack-mini/miniprogram/pages/device/scan/scan.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "扫描设备",
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
86
yack-mini/miniprogram/pages/device/scan/scan.wxml
Normal file
86
yack-mini/miniprogram/pages/device/scan/scan.wxml
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<!-- 扫描设备页面 -->
|
||||||
|
<view class="container">
|
||||||
|
<!-- 扫描状态 -->
|
||||||
|
<view class="scan-status card">
|
||||||
|
<view class="status-icon">
|
||||||
|
<image class="ble-icon" src="/assets/images/ble-scan.png" mode="aspectFit"></image>
|
||||||
|
<view class="scan-animation {{scanning ? 'scanning' : ''}}"></view>
|
||||||
|
</view>
|
||||||
|
<text class="status-text">{{statusText}}</text>
|
||||||
|
<text class="status-hint">{{statusHint}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 扫描结果列表 -->
|
||||||
|
<view class="device-list" wx:if="{{scannedDevices.length > 0}}">
|
||||||
|
<view class="list-header">
|
||||||
|
<text>发现设备 ({{scannedDevices.length}})</text>
|
||||||
|
<text class="hint">点击连接并配置</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="scanned-item" wx:for="{{scannedDevices}}" wx:key="deviceId" bindtap="handleSelectDevice" data-index="{{index}}">
|
||||||
|
<view class="item-main">
|
||||||
|
<text class="item-name">{{item.name || '未知设备'}}</text>
|
||||||
|
<text class="item-id">{{item.deviceId}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="item-rssi">
|
||||||
|
<text>{{item.RSSI}} dBm</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view class="empty" wx:elif="{{!scanning}}">
|
||||||
|
<text class="empty-text">未发现设备</text>
|
||||||
|
<text class="empty-hint">请确保设备已开机且在附近</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部按钮 -->
|
||||||
|
<view class="btn-group">
|
||||||
|
<button class="btn-scan" bindtap="handleScan" loading="{{scanning}}" disabled="{{scanning}}">
|
||||||
|
{{scanning ? '扫描中...' : '开始扫描'}}
|
||||||
|
</button>
|
||||||
|
<button class="btn-stop" wx:if="{{scanning}}" bindtap="handleStopScan">停止</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 设备配置弹窗 -->
|
||||||
|
<view class="config-modal" wx:if="{{showConfigModal}}">
|
||||||
|
<view class="modal-mask" bindtap="handleCancelConfig"></view>
|
||||||
|
<view class="modal-content">
|
||||||
|
<view class="modal-header">
|
||||||
|
<text class="modal-title">配置设备</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="modal-body">
|
||||||
|
<view class="config-item">
|
||||||
|
<text class="config-label">设备名称</text>
|
||||||
|
<input class="config-input" placeholder="给设备起个名字" value="{{configForm.name}}" bindinput="onConfigInput" data-field="name" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="config-item">
|
||||||
|
<text class="config-label">设备编号 (SN)</text>
|
||||||
|
<input class="config-input" placeholder="设备唯一标识" value="{{configForm.serialNumber}}" bindinput="onConfigInput" data-field="serialNumber" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="config-item">
|
||||||
|
<text class="config-label">产品ID</text>
|
||||||
|
<input class="config-input" type="number" placeholder="岩安测控 产品ID" value="{{configForm.productId}}" bindinput="onConfigInput" data-field="productId" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="config-item">
|
||||||
|
<text class="config-label">产品密钥</text>
|
||||||
|
<input class="config-input" placeholder="用于 MQTT 认证" value="{{configForm.productSecret}}" bindinput="onConfigInput" data-field="productSecret" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="config-item">
|
||||||
|
<text class="config-label">BLE 服务 UUID (可选)</text>
|
||||||
|
<input class="config-input" placeholder="用于过滤扫描结果" value="{{configForm.serviceUUID}}" bindinput="onConfigInput" data-field="serviceUUID" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="modal-footer">
|
||||||
|
<button class="btn-cancel" bindtap="handleCancelConfig">取消</button>
|
||||||
|
<button class="btn-confirm" bindtap="handleConfirmConfig" loading="{{configuring}}">确认保存</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
231
yack-mini/miniprogram/pages/device/scan/scan.wxss
Normal file
231
yack-mini/miniprogram/pages/device/scan/scan.wxss
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
/* 扫描页面样式 */
|
||||||
|
.scan-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 60rpx 40rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
position: relative;
|
||||||
|
width: 160rpx;
|
||||||
|
height: 160rpx;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ble-icon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-animation {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4rpx solid #1890ff;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-animation.scanning {
|
||||||
|
animation: scan-wave 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan-wave {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-hint {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanned-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 28rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-id {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-rssi {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #bbb;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 40rpx;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-scan {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-stop {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #ff4d4f;
|
||||||
|
color: #fff;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 配置弹窗 */
|
||||||
|
.config-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
position: absolute;
|
||||||
|
left: 40rpx;
|
||||||
|
right: 40rpx;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 32rpx;
|
||||||
|
border-bottom: 1rpx solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 72rpx;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
border-top: 1rpx solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel,
|
||||||
|
.btn-confirm {
|
||||||
|
flex: 1;
|
||||||
|
height: 100rpx;
|
||||||
|
line-height: 100rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
color: #666;
|
||||||
|
border-right: 1rpx solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
color: #1890ff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
119
yack-mini/miniprogram/pages/index/index.js
Normal file
119
yack-mini/miniprogram/pages/index/index.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* 首页 - 设备列表
|
||||||
|
*/
|
||||||
|
const storage = require('../../utils/storage.js');
|
||||||
|
const app = getApp();
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
userInfo: null,
|
||||||
|
devices: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
// 未登录跳转登录页
|
||||||
|
if (!app.isLoggedIn()) {
|
||||||
|
wx.reLaunch({ url: '/pages/login/login' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
// 每次显示时刷新设备列表
|
||||||
|
this.refreshData();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新数据
|
||||||
|
*/
|
||||||
|
refreshData() {
|
||||||
|
const devices = storage.getDeviceList();
|
||||||
|
this.setData({
|
||||||
|
userInfo: app.globalData.userInfo,
|
||||||
|
devices: devices,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击设备卡片 - 跳转详情页
|
||||||
|
*/
|
||||||
|
handleDeviceTap(e) {
|
||||||
|
const index = e.currentTarget.dataset.index;
|
||||||
|
const device = this.data.devices[index];
|
||||||
|
if (!device) return;
|
||||||
|
|
||||||
|
// 保存当前设备到全局
|
||||||
|
app.globalData.currentDevice = device;
|
||||||
|
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/pages/device/detail/detail?sn=${device.serialNumber}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除设备
|
||||||
|
*/
|
||||||
|
handleDeviceDelete(e) {
|
||||||
|
const serialNumber = e.detail.serialNumber;
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除设备',
|
||||||
|
content: '确定删除该设备配置吗?',
|
||||||
|
confirmColor: '#ff4d4f',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
storage.removeDevice(serialNumber);
|
||||||
|
this.refreshData();
|
||||||
|
wx.showToast({ title: '已删除', icon: 'success' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备启停切换
|
||||||
|
*/
|
||||||
|
handleDeviceSwitch(e) {
|
||||||
|
const serialNumber = e.detail.serialNumber;
|
||||||
|
const enabled = e.detail.enabled;
|
||||||
|
const devices = this.data.devices;
|
||||||
|
const index = devices.findIndex(d => d.serialNumber === serialNumber);
|
||||||
|
if (index >= 0) {
|
||||||
|
devices[index].enabled = enabled;
|
||||||
|
this.setData({ devices });
|
||||||
|
storage.updateDevice(devices[index]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加设备 - 跳转扫描页
|
||||||
|
*/
|
||||||
|
handleAddDevice() {
|
||||||
|
wx.navigateTo({
|
||||||
|
url: '/pages/device/scan/scan',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出登录
|
||||||
|
*/
|
||||||
|
handleLogout() {
|
||||||
|
wx.showModal({
|
||||||
|
title: '退出登录',
|
||||||
|
content: '确定要退出登录吗?',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
app.clearLoginInfo();
|
||||||
|
wx.reLaunch({ url: '/pages/login/login' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下拉刷新
|
||||||
|
*/
|
||||||
|
onPullDownRefresh() {
|
||||||
|
this.refreshData();
|
||||||
|
wx.stopPullDownRefresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
7
yack-mini/miniprogram/pages/index/index.json
Normal file
7
yack-mini/miniprogram/pages/index/index.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "我的设备",
|
||||||
|
"enablePullDownRefresh": false,
|
||||||
|
"usingComponents": {
|
||||||
|
"device-card": "/components/device-card/device-card"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
yack-mini/miniprogram/pages/index/index.wxml
Normal file
39
yack-mini/miniprogram/pages/index/index.wxml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<!-- 首页 - 设备列表 -->
|
||||||
|
<view class="container">
|
||||||
|
<!-- 已登录用户信息栏 -->
|
||||||
|
<view class="user-bar card" wx:if="{{userInfo}}">
|
||||||
|
<image class="user-avatar" src="{{userInfo.avatar || '/assets/images/default-avatar.png'}}" mode="aspectFill"></image>
|
||||||
|
<view class="user-info">
|
||||||
|
<text class="user-name">{{userInfo.userName}}</text>
|
||||||
|
<text class="user-id">ID: {{userInfo.userId}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="logout-btn" bindtap="handleLogout">退出</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 设备列表 -->
|
||||||
|
<view class="device-list" wx:if="{{devices.length > 0}}">
|
||||||
|
<device-card
|
||||||
|
wx:for="{{devices}}"
|
||||||
|
wx:key="serialNumber"
|
||||||
|
device="{{item}}"
|
||||||
|
bindtap="handleDeviceTap"
|
||||||
|
data-index="{{index}}"
|
||||||
|
binddelete="handleDeviceDelete"
|
||||||
|
bindswitch="handleDeviceSwitch"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view class="empty" wx:else>
|
||||||
|
<image class="empty-icon" src="/assets/images/device-empty.png" mode="aspectFit"></image>
|
||||||
|
<text class="empty-text">暂无设备</text>
|
||||||
|
<text class="empty-subtitle">点击下方按钮扫描并添加 BLE 设备</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 添加设备按钮 -->
|
||||||
|
<view class="add-btn-wrapper">
|
||||||
|
<button class="add-btn" bindtap="handleAddDevice">
|
||||||
|
+ 扫描添加设备
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
73
yack-mini/miniprogram/pages/index/index.wxss
Normal file
73
yack-mini/miniprogram/pages/index/index.wxss
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
/* 首页样式 */
|
||||||
|
.user-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
margin-right: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-id {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #ff4d4f;
|
||||||
|
padding: 8rpx 24rpx;
|
||||||
|
border: 1rpx solid #ff4d4f;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-list {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-subtitle {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #bbb;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn-wrapper {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 60rpx;
|
||||||
|
padding: 0 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
background-color: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
height: 92rpx;
|
||||||
|
line-height: 92rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
border-radius: 46rpx;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(24, 144, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:active {
|
||||||
|
background-color: #096dd9;
|
||||||
|
}
|
||||||
146
yack-mini/miniprogram/pages/login/login.js
Normal file
146
yack-mini/miniprogram/pages/login/login.js
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* 登录页面
|
||||||
|
*/
|
||||||
|
const request = require('../../utils/request.js');
|
||||||
|
const storage = require('../../utils/storage.js');
|
||||||
|
|
||||||
|
const app = getApp();
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
captchaCode: '',
|
||||||
|
captchaUuid: '',
|
||||||
|
captchaImg: '',
|
||||||
|
captchaEnabled: false,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
// 检查是否已登录
|
||||||
|
if (app.isLoggedIn()) {
|
||||||
|
this._goToIndex();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 获取验证码配置
|
||||||
|
this._checkCaptcha();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查验证码是否启用
|
||||||
|
*/
|
||||||
|
async _checkCaptcha() {
|
||||||
|
try {
|
||||||
|
const res = await request.captchaImage();
|
||||||
|
if (res.img) {
|
||||||
|
this.setData({
|
||||||
|
captchaEnabled: true,
|
||||||
|
captchaImg: 'data:image/png;base64,' + res.img,
|
||||||
|
captchaUuid: res.uuid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('[Login] 验证码获取失败,可能未启用');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新验证码
|
||||||
|
*/
|
||||||
|
refreshCaptcha() {
|
||||||
|
this._checkCaptcha();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户名输入
|
||||||
|
*/
|
||||||
|
onUsernameInput(e) {
|
||||||
|
this.setData({ username: e.detail.value });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码输入
|
||||||
|
*/
|
||||||
|
onPasswordInput(e) {
|
||||||
|
this.setData({ password: e.detail.value });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证码输入
|
||||||
|
*/
|
||||||
|
onCaptchaInput(e) {
|
||||||
|
this.setData({ captchaCode: e.detail.value });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录
|
||||||
|
*/
|
||||||
|
async handleLogin() {
|
||||||
|
const { username, password, captchaCode, captchaUuid, captchaEnabled } = this.data;
|
||||||
|
|
||||||
|
// 验证输入
|
||||||
|
if (!username) {
|
||||||
|
wx.showToast({ title: '请输入用户名', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
wx.showToast({ title: '请输入密码', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (captchaEnabled && !captchaCode) {
|
||||||
|
wx.showToast({ title: '请输入验证码', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData({ loading: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 登录请求
|
||||||
|
const loginRes = await request.login(username, password, captchaCode, captchaUuid);
|
||||||
|
const token = loginRes.token;
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
const infoRes = await request.getInfo();
|
||||||
|
const user = infoRes.user;
|
||||||
|
|
||||||
|
// 保存登录信息
|
||||||
|
app.setLoginInfo({
|
||||||
|
token: token,
|
||||||
|
userId: user.userId,
|
||||||
|
userName: user.userName,
|
||||||
|
phonenumber: user.phonenumber,
|
||||||
|
avatar: user.avatar,
|
||||||
|
});
|
||||||
|
|
||||||
|
wx.showToast({ title: '登录成功', icon: 'success' });
|
||||||
|
|
||||||
|
// 跳转首页
|
||||||
|
setTimeout(() => {
|
||||||
|
this._goToIndex();
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Login] 登录失败:', err);
|
||||||
|
wx.showToast({
|
||||||
|
title: err.message || '登录失败',
|
||||||
|
icon: 'none',
|
||||||
|
});
|
||||||
|
// 刷新验证码
|
||||||
|
if (this.data.captchaEnabled) {
|
||||||
|
this._checkCaptcha();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳转首页
|
||||||
|
*/
|
||||||
|
_goToIndex() {
|
||||||
|
wx.reLaunch({
|
||||||
|
url: '/pages/index/index',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
4
yack-mini/miniprogram/pages/login/login.json
Normal file
4
yack-mini/miniprogram/pages/login/login.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "登录",
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
40
yack-mini/miniprogram/pages/login/login.wxml
Normal file
40
yack-mini/miniprogram/pages/login/login.wxml
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<!-- 登录页面 -->
|
||||||
|
<view class="login-container">
|
||||||
|
<!-- Logo -->
|
||||||
|
<view class="login-header">
|
||||||
|
<image class="logo" src="/assets/images/logo.png" mode="aspectFit"></image>
|
||||||
|
<text class="title">岩安测控蓝牙网关</text>
|
||||||
|
<text class="subtitle">采集 BLE 设备数据,透传至云平台</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 登录表单 -->
|
||||||
|
<view class="login-form">
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">用户名</text>
|
||||||
|
<input class="input" placeholder="请输入用户名" value="{{username}}" bindinput="onUsernameInput" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">密码</text>
|
||||||
|
<input class="input" placeholder="请输入密码" password value="{{password}}" bindinput="onPasswordInput" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 验证码(可选,根据服务端配置) -->
|
||||||
|
<view class="form-item" wx:if="{{captchaEnabled}}">
|
||||||
|
<text class="label">验证码</text>
|
||||||
|
<view class="captcha-row">
|
||||||
|
<input class="input captcha-input" placeholder="请输入验证码" value="{{captchaCode}}" bindinput="onCaptchaInput" />
|
||||||
|
<image class="captcha-img" src="{{captchaImg}}" bindtap="refreshCaptcha" mode="aspectFit"></image>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button class="btn-login" bindtap="handleLogin" loading="{{loading}}" disabled="{{loading}}">
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 提示 -->
|
||||||
|
<view class="login-footer">
|
||||||
|
<text class="tip">使用岩安测控平台账号登录</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
106
yack-mini/miniprogram/pages/login/login.wxss
Normal file
106
yack-mini/miniprogram/pages/login/login.wxss
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
/* 登录页面样式 */
|
||||||
|
.login-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 80rpx 60rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 160rpx;
|
||||||
|
height: 160rpx;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-input {
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-img {
|
||||||
|
width: 200rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
background-color: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 32rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
margin-top: 40rpx;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:active {
|
||||||
|
background-color: #096dd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login[disabled] {
|
||||||
|
background-color: #69c0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 60rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
511
yack-mini/miniprogram/services/ble.js
Normal file
511
yack-mini/miniprogram/services/ble.js
Normal file
@ -0,0 +1,511 @@
|
|||||||
|
/**
|
||||||
|
* 蓝牙 BLE 采集服务
|
||||||
|
*/
|
||||||
|
const hex = require('../utils/hex.js');
|
||||||
|
|
||||||
|
// BLE 状态常量
|
||||||
|
const BLE_STATE = {
|
||||||
|
DISCONNECTED: 'disconnected',
|
||||||
|
CONNECTING: 'connecting',
|
||||||
|
CONNECTED: 'connected',
|
||||||
|
DISCOVERING: 'discovering',
|
||||||
|
READY: 'ready',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 默认配置
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
// 扫描超时时间(毫秒)
|
||||||
|
scanTimeout: 10000,
|
||||||
|
// 连接超时时间(毫秒)
|
||||||
|
connectTimeout: 10000,
|
||||||
|
// 默认服务 UUID(需根据实际设备配置)
|
||||||
|
serviceUUIDs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
class BleService {
|
||||||
|
constructor() {
|
||||||
|
this.state = BLE_STATE.DISCONNECTED;
|
||||||
|
this.deviceId = null;
|
||||||
|
this.serviceId = null;
|
||||||
|
this.notifyCharacteristicId = null;
|
||||||
|
this.writeCharacteristicId = null;
|
||||||
|
this.onDataCallback = null;
|
||||||
|
this.onStateChangeCallback = null;
|
||||||
|
this.config = { ...DEFAULT_CONFIG };
|
||||||
|
// 监听器注册标志(防止叠加)
|
||||||
|
this._listenersRegistered = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置配置
|
||||||
|
* @param {Object} config
|
||||||
|
*/
|
||||||
|
setConfig(config) {
|
||||||
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置数据回调
|
||||||
|
* @param {Function} callback - (data: {hex, buffer}) => void
|
||||||
|
*/
|
||||||
|
onData(callback) {
|
||||||
|
this.onDataCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置状态变化回调
|
||||||
|
* @param {Function} callback - (state) => void
|
||||||
|
*/
|
||||||
|
onStateChange(callback) {
|
||||||
|
this.onStateChangeCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新状态并通知
|
||||||
|
* @param {string} state
|
||||||
|
*/
|
||||||
|
_setState(state) {
|
||||||
|
this.state = state;
|
||||||
|
if (this.onStateChangeCallback) {
|
||||||
|
this.onStateChangeCallback(state);
|
||||||
|
}
|
||||||
|
console.log('[BLE] 状态变更:', state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化蓝牙适配器
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
initBluetooth() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 移除旧监听器,防止叠加(微信 API 支持注册多个回调)
|
||||||
|
if (this._listenersRegistered) {
|
||||||
|
try {
|
||||||
|
wx.offBLEConnectionStateChange();
|
||||||
|
wx.offBLECharacteristicValueChange();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略移除失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wx.openBluetoothAdapter({
|
||||||
|
success: () => {
|
||||||
|
console.log('[BLE] 初始化成功');
|
||||||
|
|
||||||
|
// 监听连接状态变化(只注册一次)
|
||||||
|
wx.onBLEConnectionStateChange((res) => {
|
||||||
|
console.log('[BLE] 连接状态变化:', res);
|
||||||
|
if (!res.connected) {
|
||||||
|
this._setState(BLE_STATE.DISCONNECTED);
|
||||||
|
this.deviceId = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听特征值变化(只注册一次)
|
||||||
|
wx.onBLECharacteristicValueChange((res) => {
|
||||||
|
const hexData = hex.arrayBufferToHex(res.value);
|
||||||
|
console.log('[BLE] 收到数据:', hexData);
|
||||||
|
if (this.onDataCallback) {
|
||||||
|
this.onDataCallback({
|
||||||
|
hex: hexData,
|
||||||
|
buffer: res.value,
|
||||||
|
deviceId: res.deviceId,
|
||||||
|
serviceId: res.serviceId,
|
||||||
|
characteristicId: res.characteristicId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._listenersRegistered = true;
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[BLE] 初始化失败:', err);
|
||||||
|
reject(new Error('蓝牙初始化失败: ' + (err.errMsg || '未知错误')));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描蓝牙设备
|
||||||
|
* @param {Array<string>} serviceUUIDs - 服务 UUID 列表(可选)
|
||||||
|
* @param {number} timeout - 超时时间(毫秒)
|
||||||
|
* @returns {Promise<Array>} 设备列表
|
||||||
|
*/
|
||||||
|
scanDevices(serviceUUIDs = null, timeout = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const uuids = serviceUUIDs || this.config.serviceUUIDs;
|
||||||
|
const scanTimeout = timeout || this.config.scanTimeout;
|
||||||
|
|
||||||
|
const devices = [];
|
||||||
|
const deviceMap = new Map();
|
||||||
|
|
||||||
|
// 移除旧的扫描监听器,防止叠加
|
||||||
|
try {
|
||||||
|
wx.offBluetoothDeviceFound();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略移除失败
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始扫描
|
||||||
|
wx.startBluetoothDevicesDiscovery({
|
||||||
|
services: uuids.length > 0 ? uuids : undefined,
|
||||||
|
allowDuplicatesKey: false,
|
||||||
|
success: () => {
|
||||||
|
console.log('[BLE] 开始扫描, 超时:', scanTimeout);
|
||||||
|
// 监听扫描结果
|
||||||
|
wx.onBluetoothDeviceFound((res) => {
|
||||||
|
res.devices.forEach((device) => {
|
||||||
|
// 去重
|
||||||
|
if (!deviceMap.has(device.deviceId)) {
|
||||||
|
deviceMap.set(device.deviceId, true);
|
||||||
|
devices.push({
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
name: device.name || '未知设备',
|
||||||
|
localName: device.localName,
|
||||||
|
advertisData: device.advertisData,
|
||||||
|
RSSI: device.RSSI,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置超时
|
||||||
|
setTimeout(() => {
|
||||||
|
wx.stopBluetoothDevicesDiscovery({
|
||||||
|
success: () => {
|
||||||
|
console.log('[BLE] 扫描完成, 发现设备:', devices.length);
|
||||||
|
// 移除扫描监听器
|
||||||
|
try {
|
||||||
|
wx.offBluetoothDeviceFound();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
resolve(devices);
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
try {
|
||||||
|
wx.offBluetoothDeviceFound();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
resolve(devices);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, scanTimeout);
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[BLE] 扫描失败:', err);
|
||||||
|
reject(new Error('扫描失败: ' + (err.errMsg || '未知错误')));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止扫描
|
||||||
|
*/
|
||||||
|
stopScan() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
wx.stopBluetoothDevicesDiscovery({
|
||||||
|
success: () => {
|
||||||
|
console.log('[BLE] 已停止扫描');
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接设备
|
||||||
|
* @param {string} deviceId - 设备 ID
|
||||||
|
* @param {string} serviceId - 服务 UUID(可选,不传则自动发现)
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
connectDevice(deviceId, serviceId = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._setState(BLE_STATE.CONNECTING);
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
|
||||||
|
const connectTimeout = this.config.connectTimeout;
|
||||||
|
let timeoutId = null;
|
||||||
|
|
||||||
|
// 设置连接超时
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
wx.closeBLEConnection({
|
||||||
|
deviceId: deviceId,
|
||||||
|
complete: () => {
|
||||||
|
this._setState(BLE_STATE.DISCONNECTED);
|
||||||
|
reject(new Error('连接超时'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, connectTimeout);
|
||||||
|
|
||||||
|
// 发起连接
|
||||||
|
wx.createBLEConnection({
|
||||||
|
deviceId: deviceId,
|
||||||
|
success: () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
console.log('[BLE] 连接成功:', deviceId);
|
||||||
|
this._setState(BLE_STATE.CONNECTED);
|
||||||
|
|
||||||
|
// 获取 MTU(提高传输效率,iOS 最大 185,Android 建议 247)
|
||||||
|
wx.setBLEMTU({
|
||||||
|
deviceId: deviceId,
|
||||||
|
mtu: 247,
|
||||||
|
complete: () => {
|
||||||
|
// 发现服务
|
||||||
|
this._discoverServices(deviceId, serviceId)
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this._setState(BLE_STATE.DISCONNECTED);
|
||||||
|
console.error('[BLE] 连接失败:', err);
|
||||||
|
reject(new Error('连接失败: ' + (err.errMsg || '未知错误')));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发现服务和特征值
|
||||||
|
* @param {string} deviceId
|
||||||
|
* @param {string} serviceId
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
_discoverServices(deviceId, serviceId = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._setState(BLE_STATE.DISCOVERING);
|
||||||
|
|
||||||
|
wx.getBLEDeviceServices({
|
||||||
|
deviceId: deviceId,
|
||||||
|
success: (res) => {
|
||||||
|
console.log('[BLE] 发现服务:', res.services);
|
||||||
|
const services = res.services;
|
||||||
|
|
||||||
|
// 选择目标服务
|
||||||
|
let targetService = null;
|
||||||
|
if (serviceId) {
|
||||||
|
// 指定了服务 UUID
|
||||||
|
targetService = services.find(s => s.uuid.toLowerCase().includes(serviceId.toLowerCase()));
|
||||||
|
} else {
|
||||||
|
// 自动选择:排除通用服务(GAP 0x1800, GATT 0x1801 等)
|
||||||
|
const GENERIC_SERVICE_PREFIXES = ['00001800', '00001801', '0000180a', '0000180f'];
|
||||||
|
targetService = services.find(s =>
|
||||||
|
!GENERIC_SERVICE_PREFIXES.some(p => s.uuid.toLowerCase().startsWith(p))
|
||||||
|
);
|
||||||
|
// 如果没有非通用服务,选择第一个主服务
|
||||||
|
if (!targetService) {
|
||||||
|
targetService = services.find(s => s.isPrimary) || services[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetService) {
|
||||||
|
reject(new Error('未找到目标服务'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serviceId = targetService.uuid;
|
||||||
|
console.log('[BLE] 目标服务:', this.serviceId);
|
||||||
|
|
||||||
|
// 获取特征值
|
||||||
|
this._discoverCharacteristics(deviceId, this.serviceId)
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject);
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[BLE] 获取服务失败:', err);
|
||||||
|
reject(new Error('获取服务失败'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发现特征值
|
||||||
|
* @param {string} deviceId
|
||||||
|
* @param {string} serviceId
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
_discoverCharacteristics(deviceId, serviceId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.getBLEDeviceCharacteristics({
|
||||||
|
deviceId: deviceId,
|
||||||
|
serviceId: serviceId,
|
||||||
|
success: (res) => {
|
||||||
|
console.log('[BLE] 发现特征值:', res.characteristics);
|
||||||
|
const characteristics = res.characteristics;
|
||||||
|
|
||||||
|
// 查找通知特征值(通常具有 notify 或 indicate 属性)
|
||||||
|
const notifyChar = characteristics.find(c => c.properties.notify || c.properties.indicate);
|
||||||
|
// 查找写入特征值
|
||||||
|
const writeChar = characteristics.find(c => c.properties.write || c.properties.writeNoResponse);
|
||||||
|
|
||||||
|
if (notifyChar) {
|
||||||
|
this.notifyCharacteristicId = notifyChar.uuid;
|
||||||
|
console.log('[BLE] 通知特征值:', this.notifyCharacteristicId);
|
||||||
|
|
||||||
|
// 启用通知
|
||||||
|
wx.notifyBLECharacteristicValueChange({
|
||||||
|
deviceId: deviceId,
|
||||||
|
serviceId: serviceId,
|
||||||
|
characteristicId: this.notifyCharacteristicId,
|
||||||
|
state: true,
|
||||||
|
success: () => {
|
||||||
|
console.log('[BLE] 已启用通知');
|
||||||
|
if (writeChar) {
|
||||||
|
this.writeCharacteristicId = writeChar.uuid;
|
||||||
|
console.log('[BLE] 写入特征值:', this.writeCharacteristicId);
|
||||||
|
}
|
||||||
|
this._setState(BLE_STATE.READY);
|
||||||
|
resolve({
|
||||||
|
serviceId: this.serviceId,
|
||||||
|
notifyCharacteristicId: this.notifyCharacteristicId,
|
||||||
|
writeCharacteristicId: this.writeCharacteristicId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[BLE] 启用通知失败:', err);
|
||||||
|
reject(new Error('启用通知失败'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 没有通知特征值,但可能有写入特征值
|
||||||
|
if (writeChar) {
|
||||||
|
this.writeCharacteristicId = writeChar.uuid;
|
||||||
|
console.log('[BLE] 写入特征值:', this.writeCharacteristicId);
|
||||||
|
}
|
||||||
|
this._setState(BLE_STATE.READY);
|
||||||
|
resolve({
|
||||||
|
serviceId: this.serviceId,
|
||||||
|
notifyCharacteristicId: null,
|
||||||
|
writeCharacteristicId: this.writeCharacteristicId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[BLE] 获取特征值失败:', err);
|
||||||
|
reject(new Error('获取特征值失败'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入数据
|
||||||
|
* @param {string} hexData - 十六进制字符串
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
writeCommand(hexData) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.deviceId || !this.serviceId || !this.writeCharacteristicId) {
|
||||||
|
reject(new Error('设备未连接或无写入特征值'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = hex.hexToArrayBuffer(hexData);
|
||||||
|
|
||||||
|
wx.writeBLECharacteristicValue({
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
serviceId: this.serviceId,
|
||||||
|
characteristicId: this.writeCharacteristicId,
|
||||||
|
value: buffer,
|
||||||
|
success: () => {
|
||||||
|
console.log('[BLE] 写入成功:', hexData);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[BLE] 写入失败:', err);
|
||||||
|
reject(new Error('写入失败: ' + (err.errMsg || '未知错误')));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断开连接
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!this.deviceId) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wx.closeBLEConnection({
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
success: () => {
|
||||||
|
console.log('[BLE] 已断开连接');
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this._setState(BLE_STATE.DISCONNECTED);
|
||||||
|
this.deviceId = null;
|
||||||
|
this.serviceId = null;
|
||||||
|
this.notifyCharacteristicId = null;
|
||||||
|
this.writeCharacteristicId = null;
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭蓝牙适配器
|
||||||
|
*/
|
||||||
|
closeBluetooth() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.disconnect().then(() => {
|
||||||
|
wx.closeBluetoothAdapter({
|
||||||
|
success: () => {
|
||||||
|
console.log('[BLE] 已关闭蓝牙适配器');
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前状态
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getState() {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否已连接
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isConnected() {
|
||||||
|
return this.state === BLE_STATE.READY || this.state === BLE_STATE.CONNECTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除数据回调(页面卸载时调用)
|
||||||
|
*/
|
||||||
|
clearCallbacks() {
|
||||||
|
this.onDataCallback = null;
|
||||||
|
this.onStateChangeCallback = null;
|
||||||
|
console.log('[BLE] 已清除回调');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
const bleService = new BleService();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
BLE_STATE,
|
||||||
|
BleService,
|
||||||
|
bleService,
|
||||||
|
};
|
||||||
317
yack-mini/miniprogram/services/mqtt-client.js
Normal file
317
yack-mini/miniprogram/services/mqtt-client.js
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* MQTT 客户端服务
|
||||||
|
*/
|
||||||
|
const mqtt = require('../libs/mqtt.min.js');
|
||||||
|
const envConfig = require('../env.config.js');
|
||||||
|
|
||||||
|
// MQTT 状态常量
|
||||||
|
const MQTT_STATE = {
|
||||||
|
DISCONNECTED: 'disconnected',
|
||||||
|
CONNECTING: 'connecting',
|
||||||
|
CONNECTED: 'connected',
|
||||||
|
};
|
||||||
|
|
||||||
|
class MqttClient {
|
||||||
|
constructor() {
|
||||||
|
this.client = null;
|
||||||
|
this.state = MQTT_STATE.DISCONNECTED;
|
||||||
|
this.subscriptions = new Map();
|
||||||
|
this.onMessageCallback = null;
|
||||||
|
this.onStateChangeCallback = null;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.maxReconnectAttempts = 5;
|
||||||
|
// 当前连接的 clientId(用于检测连接复用)
|
||||||
|
this.currentClientId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置消息回调
|
||||||
|
* @param {Function} callback - (topic, message) => void
|
||||||
|
*/
|
||||||
|
onMessage(callback) {
|
||||||
|
this.onMessageCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置状态变化回调
|
||||||
|
* @param {Function} callback - (state) => void
|
||||||
|
*/
|
||||||
|
onStateChange(callback) {
|
||||||
|
this.onStateChangeCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新状态并通知
|
||||||
|
* @param {string} state
|
||||||
|
*/
|
||||||
|
_setState(state) {
|
||||||
|
this.state = state;
|
||||||
|
if (this.onStateChangeCallback) {
|
||||||
|
this.onStateChangeCallback(state);
|
||||||
|
}
|
||||||
|
console.log('[MQTT] 状态变更:', state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接 MQTT Broker
|
||||||
|
* @param {Object} options - 连接配置
|
||||||
|
* @param {string} options.clientId - 客户端 ID
|
||||||
|
* @param {string} options.username - 用户名
|
||||||
|
* @param {string} options.password - 密码
|
||||||
|
* @param {string} options.server - 服务器地址(可选,默认使用 envConfig)
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
connect(options) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const newClientId = options.clientId;
|
||||||
|
|
||||||
|
// 检查是否需要重建连接(clientId 变化或未连接)
|
||||||
|
if (this.client && this.client.connected) {
|
||||||
|
if (this.currentClientId === newClientId) {
|
||||||
|
// 同一设备,复用连接
|
||||||
|
console.log('[MQTT] 已连接, 复用连接, clientId:', newClientId);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 不同设备,先断开旧连接,防止数据串号
|
||||||
|
console.log('[MQTT] clientId 变化,断开旧连接:', this.currentClientId, '->', newClientId);
|
||||||
|
this.client.end(true); // 强制关闭,停止重连
|
||||||
|
this.client = null;
|
||||||
|
this.subscriptions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._setState(MQTT_STATE.CONNECTING);
|
||||||
|
|
||||||
|
const server = options.server || envConfig.mqttServer;
|
||||||
|
const connectOptions = {
|
||||||
|
clientId: options.clientId,
|
||||||
|
username: options.username,
|
||||||
|
password: options.password,
|
||||||
|
clean: true,
|
||||||
|
keepalive: 30,
|
||||||
|
connectTimeout: 30000,
|
||||||
|
reconnectPeriod: 5000,
|
||||||
|
// protocol 从 URL 自动解析,无需手动设置
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[MQTT] 正在连接:', server, 'clientId:', options.clientId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.client = mqtt.connect(server, connectOptions);
|
||||||
|
this.currentClientId = options.clientId;
|
||||||
|
|
||||||
|
// 连接成功
|
||||||
|
this.client.on('connect', () => {
|
||||||
|
console.log('[MQTT] 连接成功');
|
||||||
|
this._setState(MQTT_STATE.CONNECTED);
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
// 重新订阅之前的主题
|
||||||
|
this._resubscribe();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 连接错误
|
||||||
|
this.client.on('error', (err) => {
|
||||||
|
console.error('[MQTT] 连接错误:', err);
|
||||||
|
// 强制关闭客户端,停止重连
|
||||||
|
if (this.client) {
|
||||||
|
this.client.end(true);
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
this._setState(MQTT_STATE.DISCONNECTED);
|
||||||
|
reject(new Error('MQTT 连接失败: ' + (err.message || '未知错误')));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 断开连接
|
||||||
|
this.client.on('close', () => {
|
||||||
|
console.log('[MQTT] 连接关闭');
|
||||||
|
this._setState(MQTT_STATE.DISCONNECTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重连
|
||||||
|
this.client.on('reconnect', () => {
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
console.log('[MQTT] 正在重连, 尝试次数:', this.reconnectAttempts);
|
||||||
|
this._setState(MQTT_STATE.CONNECTING);
|
||||||
|
if (this.reconnectAttempts > this.maxReconnectAttempts) {
|
||||||
|
console.log('[MQTT] 重连次数超限,停止重连');
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 收到消息
|
||||||
|
this.client.on('message', (topic, message) => {
|
||||||
|
const msgStr = message.toString();
|
||||||
|
console.log('[MQTT] 收到消息, topic:', topic, 'message:', msgStr);
|
||||||
|
if (this.onMessageCallback) {
|
||||||
|
this.onMessageCallback(topic, msgStr);
|
||||||
|
}
|
||||||
|
// 触发订阅回调
|
||||||
|
if (this.subscriptions.has(topic)) {
|
||||||
|
const callback = this.subscriptions.get(topic);
|
||||||
|
callback(topic, msgStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 离线
|
||||||
|
this.client.on('offline', () => {
|
||||||
|
console.log('[MQTT] 离线');
|
||||||
|
this._setState(MQTT_STATE.DISCONNECTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MQTT] 创建客户端失败:', err);
|
||||||
|
this._setState(MQTT_STATE.DISCONNECTED);
|
||||||
|
reject(new Error('创建 MQTT 客户端失败'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布消息
|
||||||
|
* @param {string} topic - 主题
|
||||||
|
* @param {string} message - 消息内容
|
||||||
|
* @param {Object} options - 发布选项 {qos: 0|1|2, retain: boolean}
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
publish(topic, message, options = { qos: 1 }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.client || !this.client.connected) {
|
||||||
|
reject(new Error('MQTT 未连接'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client.publish(topic, message, options, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[MQTT] 发布失败:', err);
|
||||||
|
reject(new Error('发布失败'));
|
||||||
|
} else {
|
||||||
|
console.log('[MQTT] 发布成功, topic:', topic, 'message:', message);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅主题
|
||||||
|
* @param {string} topic - 主题
|
||||||
|
* @param {Function} callback - 消息回调(可选)
|
||||||
|
* @param {Object} options - 订阅选项 {qos: 0|1|2}
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
subscribe(topic, callback = null, options = { qos: 1 }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.client || !this.client.connected) {
|
||||||
|
reject(new Error('MQTT 未连接'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client.subscribe(topic, options, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[MQTT] 订阅失败:', err);
|
||||||
|
reject(new Error('订阅失败'));
|
||||||
|
} else {
|
||||||
|
console.log('[MQTT] 订阅成功:', topic);
|
||||||
|
if (callback) {
|
||||||
|
this.subscriptions.set(topic, callback);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消订阅
|
||||||
|
* @param {string} topic - 主题
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
unsubscribe(topic) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.client || !this.client.connected) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client.unsubscribe(topic, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[MQTT] 取消订阅失败:', err);
|
||||||
|
reject(new Error('取消订阅失败'));
|
||||||
|
} else {
|
||||||
|
console.log('[MQTT] 取消订阅成功:', topic);
|
||||||
|
this.subscriptions.delete(topic);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新订阅所有主题
|
||||||
|
*/
|
||||||
|
_resubscribe() {
|
||||||
|
if (this.subscriptions.size === 0) return;
|
||||||
|
|
||||||
|
this.subscriptions.forEach((callback, topic) => {
|
||||||
|
this.subscribe(topic, callback).catch(err => {
|
||||||
|
console.error('[MQTT] 重订阅失败:', topic, err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断开连接
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!this.client) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client.end(false, () => {
|
||||||
|
console.log('[MQTT] 已断开连接');
|
||||||
|
this._setState(MQTT_STATE.DISCONNECTED);
|
||||||
|
this.client = null;
|
||||||
|
this.currentClientId = null;
|
||||||
|
this.subscriptions.clear();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前状态
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getState() {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否已连接
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isConnected() {
|
||||||
|
return this.client && this.client.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除消息回调(页面卸载时调用)
|
||||||
|
*/
|
||||||
|
clearCallbacks() {
|
||||||
|
this.onMessageCallback = null;
|
||||||
|
this.onStateChangeCallback = null;
|
||||||
|
console.log('[MQTT] 已清除回调');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
const mqttClient = new MqttClient();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MQTT_STATE,
|
||||||
|
MqttClient,
|
||||||
|
mqttClient,
|
||||||
|
};
|
||||||
7
yack-mini/miniprogram/sitemap.json
Normal file
7
yack-mini/miniprogram/sitemap.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
|
||||||
|
"rules": [{
|
||||||
|
"action": "allow",
|
||||||
|
"page": "*"
|
||||||
|
}]
|
||||||
|
}
|
||||||
116
yack-mini/miniprogram/utils/auth.js
Normal file
116
yack-mini/miniprogram/utils/auth.js
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* 认证工具 - 获取 MQTT 凭证
|
||||||
|
*/
|
||||||
|
const storage = require('./storage.js');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 MQTT clientId
|
||||||
|
* 格式:S&{设备编号}&{产品ID}&{用户ID}
|
||||||
|
* @param {string} serialNumber - 设备编号
|
||||||
|
* @param {number|string} productId - 产品ID
|
||||||
|
* @param {number|string} userId - 用户ID
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function generateClientId(serialNumber, productId, userId) {
|
||||||
|
return `S&${serialNumber}&${productId}&${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 MQTT 连接凭证
|
||||||
|
* @param {string} serialNumber - 设备编号
|
||||||
|
* @param {number|string} productId - 产品ID
|
||||||
|
* @param {string} productSecret - 产品密钥
|
||||||
|
* @returns {Object|null} { clientId, username, password }
|
||||||
|
*/
|
||||||
|
function getMqttCredentials(serialNumber, productId, productSecret) {
|
||||||
|
const userInfo = storage.getUserInfo();
|
||||||
|
if (!userInfo || !userInfo.userId) {
|
||||||
|
console.error('[Auth] 未登录,无法获取 MQTT 凭证');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = userInfo.userId;
|
||||||
|
const clientId = generateClientId(serialNumber, productId, userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId: clientId,
|
||||||
|
username: serialNumber,
|
||||||
|
password: productSecret,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从设备配置获取 MQTT 凭证
|
||||||
|
* @param {Object} device - 设备配置对象
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
function getMqttCredentialsFromDevice(device) {
|
||||||
|
if (!device || !device.serialNumber || !device.productId || !device.productSecret) {
|
||||||
|
console.error('[Auth] 设备配置不完整');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getMqttCredentials(device.serialNumber, device.productId, device.productSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建属性上报 Topic
|
||||||
|
* 格式:/{productId}/{serialNumber}/property/post
|
||||||
|
* @param {number|string} productId - 产品ID
|
||||||
|
* @param {string} serialNumber - 设备编号
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function buildPropertyPostTopic(productId, serialNumber) {
|
||||||
|
return `/${productId}/${serialNumber}/property/post`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建功能下发 Topic
|
||||||
|
* 格式:/{productId}/{serialNumber}/function/get
|
||||||
|
* @param {number|string} productId - 产品ID
|
||||||
|
* @param {string} serialNumber - 设备编号
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function buildFunctionGetTopic(productId, serialNumber) {
|
||||||
|
return `/${productId}/${serialNumber}/function/get`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建属性读取 Topic
|
||||||
|
* 格式:/{productId}/{serialNumber}/property/get
|
||||||
|
* @param {number|string} productId - 产品ID
|
||||||
|
* @param {string} serialNumber - 设备编号
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function buildPropertyGetTopic(productId, serialNumber) {
|
||||||
|
return `/${productId}/${serialNumber}/property/get`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查登录状态
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function checkLoginStatus() {
|
||||||
|
const token = storage.getToken();
|
||||||
|
const userInfo = storage.getUserInfo();
|
||||||
|
return !!(token && userInfo && userInfo.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户ID
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
function getCurrentUserId() {
|
||||||
|
const userInfo = storage.getUserInfo();
|
||||||
|
return userInfo ? userInfo.userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateClientId,
|
||||||
|
getMqttCredentials,
|
||||||
|
getMqttCredentialsFromDevice,
|
||||||
|
buildPropertyPostTopic,
|
||||||
|
buildFunctionGetTopic,
|
||||||
|
buildPropertyGetTopic,
|
||||||
|
checkLoginStatus,
|
||||||
|
getCurrentUserId,
|
||||||
|
};
|
||||||
119
yack-mini/miniprogram/utils/hex.js
Normal file
119
yack-mini/miniprogram/utils/hex.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* 十六进制工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ArrayBuffer 转十六进制字符串
|
||||||
|
* @param {ArrayBuffer} buffer - ArrayBuffer 数据
|
||||||
|
* @returns {string} 十六进制字符串(大写)
|
||||||
|
*/
|
||||||
|
function arrayBufferToHex(buffer) {
|
||||||
|
const hexArr = Array.prototype.map.call(
|
||||||
|
new Uint8Array(buffer),
|
||||||
|
function (byte) {
|
||||||
|
return ('0' + byte.toString(16)).slice(-2);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return hexArr.join('').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 十六进制字符串转 ArrayBuffer
|
||||||
|
* @param {string} hex - 十六进制字符串
|
||||||
|
* @returns {ArrayBuffer} ArrayBuffer 数据
|
||||||
|
*/
|
||||||
|
function hexToArrayBuffer(hex) {
|
||||||
|
// 去除空格和换行
|
||||||
|
hex = hex.replace(/\s+/g, '');
|
||||||
|
|
||||||
|
// 确保长度为偶数
|
||||||
|
if (hex.length % 2 !== 0) {
|
||||||
|
hex = '0' + hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = hex.length / 2;
|
||||||
|
const buffer = new ArrayBuffer(length);
|
||||||
|
const dataView = new DataView(buffer);
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const byte = parseInt(hex.substr(i * 2, 2), 16);
|
||||||
|
dataView.setUint8(i, byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 十六进制字符串转字节数组
|
||||||
|
* @param {string} hex - 十六进制字符串
|
||||||
|
* @returns {Array<number>} 字节数组
|
||||||
|
*/
|
||||||
|
function hexToBytes(hex) {
|
||||||
|
hex = hex.replace(/\s+/g, '');
|
||||||
|
const bytes = [];
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes.push(parseInt(hex.substr(i, 2), 16));
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字节数组转十六进制字符串
|
||||||
|
* @param {Array<number>} bytes - 字节数组
|
||||||
|
* @returns {string} 十六进制字符串
|
||||||
|
*/
|
||||||
|
function bytesToHex(bytes) {
|
||||||
|
return bytes
|
||||||
|
.map(b => ('0' + b.toString(16)).slice(-2))
|
||||||
|
.join('')
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRC16 校验(Modbus RTU)
|
||||||
|
* @param {string} hex - 十六进制字符串(不含 CRC)
|
||||||
|
* @returns {string} CRC16 校验码(4位十六进制)
|
||||||
|
*/
|
||||||
|
function crc16(hex) {
|
||||||
|
const bytes = hexToBytes(hex);
|
||||||
|
let crc = 0xFFFF;
|
||||||
|
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
crc ^= bytes[i];
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
if (crc & 0x0001) {
|
||||||
|
crc = (crc >> 1) ^ 0xA001;
|
||||||
|
} else {
|
||||||
|
crc = crc >> 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回低位在前,高位在后
|
||||||
|
const low = (crc & 0xFF).toString(16).padStart(2, '0');
|
||||||
|
const high = ((crc >> 8) & 0xFF).toString(16).padStart(2, '0');
|
||||||
|
return (low + high).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 CRC16 校验码
|
||||||
|
* @param {string} hex - 完整的十六进制字符串(含 CRC)
|
||||||
|
* @returns {boolean} 校验是否通过
|
||||||
|
*/
|
||||||
|
function verifyCrc16(hex) {
|
||||||
|
if (hex.length < 4) return false;
|
||||||
|
|
||||||
|
const data = hex.slice(0, -4);
|
||||||
|
const crc = hex.slice(-4);
|
||||||
|
|
||||||
|
return crc16(data) === crc;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
arrayBufferToHex,
|
||||||
|
hexToArrayBuffer,
|
||||||
|
hexToBytes,
|
||||||
|
bytesToHex,
|
||||||
|
crc16,
|
||||||
|
verifyCrc16,
|
||||||
|
};
|
||||||
143
yack-mini/miniprogram/utils/request.js
Normal file
143
yack-mini/miniprogram/utils/request.js
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* 网络请求工具
|
||||||
|
* 仅用于登录流程(获取验证码、登录、获取用户信息)
|
||||||
|
* 业务流程(MQTT 连接、数据上报)不依赖此模块
|
||||||
|
*/
|
||||||
|
const envConfig = require('../env.config.js');
|
||||||
|
const storage = require('./storage.js');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起 HTTP 请求
|
||||||
|
* @param {Object} options - 请求配置
|
||||||
|
* @param {string} options.url - 请求地址
|
||||||
|
* @param {string} options.method - 请求方法
|
||||||
|
* @param {Object} options.data - 请求数据
|
||||||
|
* @param {Object} options.header - 请求头
|
||||||
|
* @param {boolean} options.auth - 是否需要认证
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
function request(options) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const header = options.header || {};
|
||||||
|
|
||||||
|
// 添加 Token
|
||||||
|
if (options.auth !== false) {
|
||||||
|
const token = storage.getToken();
|
||||||
|
if (token) {
|
||||||
|
header['Authorization'] = 'Bearer ' + token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wx.request({
|
||||||
|
url: envConfig.baseUrl + options.url,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
data: options.data || {},
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...header,
|
||||||
|
},
|
||||||
|
success(res) {
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
// yack 统一响应格式
|
||||||
|
if (res.data.code === 200) {
|
||||||
|
resolve(res.data);
|
||||||
|
} else {
|
||||||
|
// 业务错误
|
||||||
|
const errMsg = res.data.msg || '请求失败';
|
||||||
|
console.error('[Request] 业务错误:', errMsg);
|
||||||
|
reject(new Error(errMsg));
|
||||||
|
}
|
||||||
|
} else if (res.statusCode === 401) {
|
||||||
|
// Token 过期(仅登录流程使用,业务不依赖 token)
|
||||||
|
console.error('[Request] 未授权');
|
||||||
|
// 不清除登录态,不强制跳转(业务流程独立于 token)
|
||||||
|
reject(new Error('登录已过期'));
|
||||||
|
} else {
|
||||||
|
console.error('[Request] HTTP 错误:', res.statusCode);
|
||||||
|
reject(new Error('网络请求失败'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail(err) {
|
||||||
|
console.error('[Request] 网络错误:', err);
|
||||||
|
reject(new Error('网络连接失败'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET 请求
|
||||||
|
* @param {string} url - 请求地址
|
||||||
|
* @param {Object} data - 查询参数
|
||||||
|
* @param {Object} options - 其他配置
|
||||||
|
*/
|
||||||
|
function get(url, data = {}, options = {}) {
|
||||||
|
return request({
|
||||||
|
url,
|
||||||
|
method: 'GET',
|
||||||
|
data,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST 请求
|
||||||
|
* @param {string} url - 请求地址
|
||||||
|
* @param {Object} data - 请求数据
|
||||||
|
* @param {Object} options - 其他配置
|
||||||
|
*/
|
||||||
|
function post(url, data = {}, options = {}) {
|
||||||
|
return request({
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录接口
|
||||||
|
* @param {string} username
|
||||||
|
* @param {string} password
|
||||||
|
* @param {string} code - 验证码(可选)
|
||||||
|
* @param {string} uuid - 验证码 UUID(可选)
|
||||||
|
*/
|
||||||
|
function login(username, password, code = '', uuid = '') {
|
||||||
|
return post('/login', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
code,
|
||||||
|
uuid,
|
||||||
|
}, { auth: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户信息
|
||||||
|
*/
|
||||||
|
function getInfo() {
|
||||||
|
return get('/getInfo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出登录
|
||||||
|
*/
|
||||||
|
function logout() {
|
||||||
|
return post('/logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取验证码
|
||||||
|
*/
|
||||||
|
function captchaImage() {
|
||||||
|
return get('/captchaImage', { showCode: 'false' });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
request,
|
||||||
|
get,
|
||||||
|
post,
|
||||||
|
login,
|
||||||
|
getInfo,
|
||||||
|
logout,
|
||||||
|
captchaImage,
|
||||||
|
};
|
||||||
227
yack-mini/miniprogram/utils/storage.js
Normal file
227
yack-mini/miniprogram/utils/storage.js
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* 本地存储工具
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'yack_token';
|
||||||
|
const USER_INFO_KEY = 'yack_user_info';
|
||||||
|
const DEVICE_LIST_KEY = 'yack_device_list';
|
||||||
|
const PENDING_DATA_KEY = 'yack_pending_data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Token
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function getToken() {
|
||||||
|
try {
|
||||||
|
return wx.getStorageSync(TOKEN_KEY) || null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] getToken error:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 Token
|
||||||
|
* @param {string} token
|
||||||
|
*/
|
||||||
|
function setToken(token) {
|
||||||
|
try {
|
||||||
|
wx.setStorageSync(TOKEN_KEY, token);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] setToken error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除 Token
|
||||||
|
*/
|
||||||
|
function removeToken() {
|
||||||
|
try {
|
||||||
|
wx.removeStorageSync(TOKEN_KEY);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] removeToken error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户信息
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
function getUserInfo() {
|
||||||
|
try {
|
||||||
|
return wx.getStorageSync(USER_INFO_KEY) || null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] getUserInfo error:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置用户信息
|
||||||
|
* @param {Object} userInfo
|
||||||
|
*/
|
||||||
|
function setUserInfo(userInfo) {
|
||||||
|
try {
|
||||||
|
wx.setStorageSync(USER_INFO_KEY, userInfo);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] setUserInfo error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除用户信息
|
||||||
|
*/
|
||||||
|
function removeUserInfo() {
|
||||||
|
try {
|
||||||
|
wx.removeStorageSync(USER_INFO_KEY);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] removeUserInfo error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设备列表
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
function getDeviceList() {
|
||||||
|
try {
|
||||||
|
return wx.getStorageSync(DEVICE_LIST_KEY) || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] getDeviceList error:', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存设备列表
|
||||||
|
* @param {Array} devices
|
||||||
|
*/
|
||||||
|
function saveDeviceList(devices) {
|
||||||
|
try {
|
||||||
|
wx.setStorageSync(DEVICE_LIST_KEY, devices);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] saveDeviceList error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加设备
|
||||||
|
* @param {Object} device
|
||||||
|
*/
|
||||||
|
function addDevice(device) {
|
||||||
|
const devices = getDeviceList();
|
||||||
|
// 检查是否已存在
|
||||||
|
const index = devices.findIndex(d => d.serialNumber === device.serialNumber);
|
||||||
|
if (index >= 0) {
|
||||||
|
devices[index] = device;
|
||||||
|
} else {
|
||||||
|
devices.push(device);
|
||||||
|
}
|
||||||
|
saveDeviceList(devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除设备
|
||||||
|
* @param {string} serialNumber
|
||||||
|
*/
|
||||||
|
function removeDevice(serialNumber) {
|
||||||
|
const devices = getDeviceList();
|
||||||
|
const newDevices = devices.filter(d => d.serialNumber !== serialNumber);
|
||||||
|
saveDeviceList(newDevices);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新设备
|
||||||
|
* @param {Object} device
|
||||||
|
*/
|
||||||
|
function updateDevice(device) {
|
||||||
|
const devices = getDeviceList();
|
||||||
|
const index = devices.findIndex(d => d.serialNumber === device.serialNumber);
|
||||||
|
if (index >= 0) {
|
||||||
|
devices[index] = device;
|
||||||
|
saveDeviceList(devices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个设备
|
||||||
|
* @param {string} serialNumber
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
function getDevice(serialNumber) {
|
||||||
|
const devices = getDeviceList();
|
||||||
|
return devices.find(d => d.serialNumber === serialNumber) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存待上报数据
|
||||||
|
* @param {Object} data
|
||||||
|
*/
|
||||||
|
function cachePendingData(data) {
|
||||||
|
try {
|
||||||
|
const pendingList = wx.getStorageSync(PENDING_DATA_KEY) || [];
|
||||||
|
pendingList.push({
|
||||||
|
...data,
|
||||||
|
cacheTime: Date.now(),
|
||||||
|
});
|
||||||
|
wx.setStorageSync(PENDING_DATA_KEY, pendingList);
|
||||||
|
console.log('[Storage] 缓存待上报数据:', data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] cachePendingData error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取待上报数据
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
function getPendingData() {
|
||||||
|
try {
|
||||||
|
return wx.getStorageSync(PENDING_DATA_KEY) || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] getPendingData error:', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除待上报数据
|
||||||
|
*/
|
||||||
|
function clearPendingData() {
|
||||||
|
try {
|
||||||
|
wx.removeStorageSync(PENDING_DATA_KEY);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] clearPendingData error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有数据
|
||||||
|
*/
|
||||||
|
function clearAll() {
|
||||||
|
try {
|
||||||
|
wx.clearStorageSync();
|
||||||
|
console.log('[Storage] 已清除所有数据');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] clearAll error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getToken,
|
||||||
|
setToken,
|
||||||
|
removeToken,
|
||||||
|
getUserInfo,
|
||||||
|
setUserInfo,
|
||||||
|
removeUserInfo,
|
||||||
|
getDeviceList,
|
||||||
|
saveDeviceList,
|
||||||
|
addDevice,
|
||||||
|
removeDevice,
|
||||||
|
updateDevice,
|
||||||
|
getDevice,
|
||||||
|
cachePendingData,
|
||||||
|
getPendingData,
|
||||||
|
clearPendingData,
|
||||||
|
clearAll,
|
||||||
|
};
|
||||||
52
yack-mini/project.config.json
Normal file
52
yack-mini/project.config.json
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"description": "yack-mini - 岩安测控蓝牙网关微信小程序",
|
||||||
|
"packOptions": {
|
||||||
|
"ignore": [],
|
||||||
|
"include": []
|
||||||
|
},
|
||||||
|
"setting": {
|
||||||
|
"bundle": false,
|
||||||
|
"userConfirmedBundleSwitch": false,
|
||||||
|
"urlCheck": true,
|
||||||
|
"scopeDataCheck": false,
|
||||||
|
"coverView": true,
|
||||||
|
"es6": true,
|
||||||
|
"postcss": true,
|
||||||
|
"compileHotReLoad": false,
|
||||||
|
"lazyloadPlaceholderEnable": false,
|
||||||
|
"preloadBackgroundData": false,
|
||||||
|
"minified": true,
|
||||||
|
"autoAudits": false,
|
||||||
|
"newFeature": false,
|
||||||
|
"uglifyFileName": false,
|
||||||
|
"uploadWithSourceMap": true,
|
||||||
|
"useIsolateContext": true,
|
||||||
|
"nodeModules": false,
|
||||||
|
"enhance": true,
|
||||||
|
"useMultiFrameRuntime": true,
|
||||||
|
"useApiHook": true,
|
||||||
|
"useApiHostProcess": true,
|
||||||
|
"showShadowRootInWxmlPanel": true,
|
||||||
|
"packNpmManually": false,
|
||||||
|
"enableEngineNative": false,
|
||||||
|
"packNpmRelationList": [],
|
||||||
|
"minifyWXSS": true,
|
||||||
|
"showES6CompileOption": false,
|
||||||
|
"minifyWXML": true,
|
||||||
|
"babelSetting": {
|
||||||
|
"ignore": [],
|
||||||
|
"disablePlugins": [],
|
||||||
|
"outputPath": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compileType": "miniprogram",
|
||||||
|
"libVersion": "3.3.4",
|
||||||
|
"appid": "wx0000000000000000",
|
||||||
|
"projectname": "yack-mini",
|
||||||
|
"condition": {},
|
||||||
|
"editorSetting": {
|
||||||
|
"tabIndent": "insertSpaces",
|
||||||
|
"tabSize": 2
|
||||||
|
},
|
||||||
|
"miniprogramRoot": "miniprogram/"
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user