20260905:微信小程序-蓝牙采集网关

This commit is contained in:
zhugongping 2026-09-05 10:19:07 +08:00
parent d6377494b4
commit 21cd8337da
45 changed files with 4280 additions and 1 deletions

View File

@ -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"
] ]
} }
} }

View 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
View 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。

View 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
View File

@ -0,0 +1,12 @@
# 微信开发者工具
.DS_Store
*.mp3
*.mp4
# 依赖目录(如果有使用 npm)
node_modules/
miniprogram_npm/
# 临时文件
*.log
*.tmp

205
yack-mini/README.md Normal file
View 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

View 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;
},
});

View 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"
}

View 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View 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,
});
},
},
});

View File

@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@ -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>

View File

@ -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;
}

View 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;

File diff suppressed because one or more lines are too long

View 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}`;
},
});

View File

@ -0,0 +1,5 @@
{
"navigationBarTitleText": "设备详情",
"enablePullDownRefresh": false,
"usingComponents": {}
}

View 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>

View 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;
}

View 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 });
}
},
});

View File

@ -0,0 +1,4 @@
{
"navigationBarTitleText": "扫描设备",
"usingComponents": {}
}

View 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>

View 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;
}

View 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();
},
});

View File

@ -0,0 +1,7 @@
{
"navigationBarTitleText": "我的设备",
"enablePullDownRefresh": false,
"usingComponents": {
"device-card": "/components/device-card/device-card"
}
}

View 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>

View 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;
}

View 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',
});
},
});

View File

@ -0,0 +1,4 @@
{
"navigationBarTitleText": "登录",
"usingComponents": {}
}

View 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>

View 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;
}

View 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,
};

View 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,
};

View File

@ -0,0 +1,7 @@
{
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
"rules": [{
"action": "allow",
"page": "*"
}]
}

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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/"
}