MINI:优化小程序P0问题

This commit is contained in:
zhugongping 2026-09-27 15:17:17 +08:00
parent 1e1f2ba405
commit 5738dfb9e3
11 changed files with 689 additions and 156 deletions

View File

@ -6,7 +6,7 @@
-- =============================================
-- 测点表(measure_point_id 为测点唯一识别号,前端手动输入)
CREATE TABLE IF NOT EXISTS `iot_measure_point` (
CREATE TABLE `iot_measure_point` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`measure_point_id` VARCHAR(64) NOT NULL COMMENT '唯一采集ID(测点唯一识别号,前端输入,正则^[A-Za-z0-9_-]{1,64}$)',
`product_id` BIGINT DEFAULT NULL COMMENT '产品id',
@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS `iot_measure_point` (
-- 物模型改名后历史帧仍保留采集当时的名称,列表列头也直接由数据自身提供。
-- =============================================
CREATE TABLE IF NOT EXISTS `iot_measure_point_data` (
CREATE TABLE `iot_measure_point_data` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键(帧标识)',
`measure_point_id` VARCHAR(64) NOT NULL COMMENT '测点唯一识别号',
`product_id` BIGINT DEFAULT NULL COMMENT '产品id',

View File

@ -0,0 +1,307 @@
# yack-mini 小程序 Code Review
> 评审范围:`yack-mini/miniprogram/**`(业务代码约 6.0k 行,不含第三方 `libs/mqtt-wx.min.js` 887 行)
> 评审日期:2026-09-27
> 结论:**架构分层清晰、BLE 状态机工程质量高**,主要债务集中在「页面层的脏边界」与「重构后未清理的死代码」。
## 一、总体评价
| 层 | 文件 | 评价 |
| --- | --- | --- |
| BLE 状态机 | `services/ble.js` (1213) | ⭐ 优秀。把微信 BLE 的三条硬约束(already connect / 并发建链 / 切设备作废)用 seq + 单 Promise 闸门建模,每条约束都有注释说明来源,超时兜底齐全。全项目最扎实的模块 |
| 采集网关 | `services/gateway.js` (872) | ⭐ 良好。串行队列 `_chain` + 状态快照订阅 + 收帧快照归属(测点/productId/reportSn)设计正确,注释解释了"为什么"而非"是什么" |
| MQTT 包装 | `services/mqtt-client.js` (491) | 良好。实例校验防幽灵客户端、建连去重、用户意图标记都到位 |
| 页面层 | `pages/**` | ⚠️ 一般。脏边界集中在此:参数无校验、高频 setData、过期注释 |
| 工具层 | `utils/**` | ⚠️ 一般。死函数多,且存在一处会损坏数据的编码缺陷 |
| 回归测试 | `tests/**` | 良好质量,但**覆盖缺口大**:只锁了"设备停用不连蓝牙"和"三个开关"两个点,主链路(收帧→上报→离线补传)零覆盖 |
---
## 二、P0 — 会导致功能错误或数据损坏,必修
> ✅ **以下 5 条已于 2026-09-27 全部修复**,回归用例见 `tests/p0-regressions.test.js`(45 断言,含反向验证:
> 回退成缺陷写法后 5 项转红)。改动文件:`pages/mine/mine.js`、`utils/storage.js`、`pages/device/scan/scan.js`、
> `utils/crypto.js`、`services/mqtt-client.js`。P1 及以下未动。
### P0-1 产品ID 用 `parseInt` 无整数校验,NaN 会写进上报 topic
- 位置:`pages/mine/mine.js:98`、`mine.js:103`
- 根因:`parseInt('12.5') === 12`(静默截断小数)、`parseInt('abc') === NaN`。`if (!productId)` 只挡空串,NaN 能一路穿到 `storage.setMqttConfig({ productId: NaN })`。后续 `gateway._resolveProductId()` 的判空是 `cfg.productId == null || cfg.productId === ''`,**NaN 两个都不满足**,于是构造出 `/NaN/{sn}/{测点}/measure/post` 这样的脏 topic 并持续上报。
- 修复:
```js
// mine.js handleMqttConnect()
const pid = Number(productId);
if (!Number.isInteger(pid) || pid <= 0) {
wx.showToast({ title: '产品ID必须是正整数', icon: 'none' });
return;
}
const creds = auth.getMqttCredentials(serialNumber, pid, productSecret);
...
storage.setMqttConfig({ serialNumber, productId: pid, productSecret });
```
### P0-2 设备数达上限时 `addDevice` 静默失败,UI 谎报"已连接"
- 位置:`utils/storage.js:132-139`(超限 `return`,无返回值)+ `pages/device/scan/scan.js:145-160`
- 根因:`addDevice` 成功路径没有 `return`,超限路径 `return undefined`。scan 页不检查返回值,继续执行 `activateDevice`(找不到该 SN,空操作)→ `getDevice` 返回 null → `gateway.start(deviceConfig)` 传入一个**根本没落盘**的设备 → `_startNow` 里 `_isEnabled()` 为 false → 不采集。用户看到 toast "已连接设备",实际既没保存也没连。
- 修复(两处):
```js
// storage.js
function addDevice(device) {
const devices = getDeviceList();
const index = devices.findIndex(d => d.serialNumber === device.serialNumber);
const encryptedDevice = encryptFields(device, ENCRYPT_FIELDS);
if (index >= 0) {
devices[index] = encryptedDevice;
} else {
if (devices.length >= LIMITS.MAX_DEVICE_COUNT) {
console.warn('[Storage] 设备数量已达上限:', LIMITS.MAX_DEVICE_COUNT);
return false; // ← 明确失败语义
}
devices.push(encryptedDevice);
}
saveDeviceList(devices);
return true;
}
// scan.js handleSelectDevice()
if (!storage.addDevice(deviceConfig)) {
wx.hideLoading();
wx.showToast({ title: `最多添加 ${50} 个设备`, icon: 'none' });
return;
}
storage.activateDevice(serialNumber);
```
### P0-3 Base64 编码用 `setUint8` 静默截断,非 ASCII 密钥加密后不可逆损坏
- 位置:`utils/crypto.js:19-24`
- 根因:`dataView.setUint8(i, str.charCodeAt(i))` 只写低 8 位。XOR 后码点可能 > 255(例如中文 `0x4E2D` XOR `0x66` = `0x4E4B` = 20043),被静默截断 → 解密得到乱码 → **产品密钥永久损坏且无报错**。密钥通常纯 ASCII 所以没暴露,但这是"输入一旦含非 Latin-1 字符就毁数据"的定时炸弹。
- 修复:
```js
function base64Encode(str) {
try {
const bytes = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code > 0xFF) {
console.error('[Crypto] 含非 Latin-1 字符,拒绝编码:', i);
return '';
}
bytes[i] = code;
}
return wx.arrayBufferToBase64(bytes.buffer);
} catch (e) {
console.error('[Crypto] Base64 编码失败:', e);
return '';
}
}
```
> 附带:`encrypt/decrypt` 异常时 **fail-open 返回原文**(`crypto.js:71`、`crypto.js:94`),会把明文当密文落盘。建议改为返回空串并让调用方拒绝写入。
### P0-4 切换 clientId 时订阅表被清空,`_resubscribe` 恢复不到任何东西
- 位置:`services/mqtt-client.js:177-181` vs `mqtt-client.js:409-417`
- 根因:`connect()` 在拆旧客户端时 `this.subscriptions.clear()`,而 `_resubscribe()` 只在 `client.on('connect')` 里执行 —— 执行时表已空。**任何一次换 clientId 重连都会永久丢失全部订阅**。当前业务只 publish 不 subscribe,所以未暴露;一旦接入下行指令就是线上事故。
- 修复:把"订阅表"的生命周期从"连接"解耦 —— 只在 `disconnect()`(用户意图)时清,换 clientId 时保留:
```js
// connect() 内
if (this.client) {
console.log('[MQTT] 终止旧客户端:', this.currentClientId, '->', newClientId);
this._teardownClient(this.client); // 保留 this.subscriptions,交由 _resubscribe 恢复
}
```
### P0-5 离线补传串行 `await publish`,publish 无超时 → 一次不结算就永久堵死
- 位置:`services/gateway.js:733-772`(`_flushOffline`)+ `services/mqtt-client.js:332-349`(`publish`)
- 根因:`_flushOffline` 用 `await mqttClient.publish(...)` 逐条串行发送,且被 `_flushingOffline` 布尔闸门保护。而 `publish` 的 resolve/reject **完全依赖底层回调**:QoS1 的回调要等 PUBACK 或重试用尽。若底层在 ws 已断、`_inflight` 条目回调路径异常时永不结算,`_flushingOffline` 将永远停在 `true`(`finally` 永远执行不到)→ **此后所有离线补传静默失效,直到重启小程序**。BLE 侧所有 GATT 操作都做了超时兜底,唯独 publish 没有,属于防御不一致。
- 修复:给 publish 加超时出口(与 BLE 的 `_callWithTimeout` 同一模式):
```js
// mqtt-client.js
publish(topic, message, options = { qos: 1 }) {
const timeout = options.publishTimeout != null ? options.publishTimeout : 15000;
return new Promise((resolve, reject) => {
if (!this.client || !this.client.connected) {
reject(new Error('MQTT 未连接'));
return;
}
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
reject(new Error('发布超时'));
}, timeout);
this.client.publish(topic, message, options, (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (err) { console.error('[MQTT] 发布失败:', err); reject(new Error('发布失败')); }
else { resolve(); }
});
});
}
```
> 另外 `_flushOffline` 建议加并发上限或分批,避免 1000 条缓存时逐条串行 await 拖很久。
---
## 三、P1 — 隐患 / 性能 / 一致性
### P1-1 通知风暴:每次 `_notify` 全量构造快照,页面再全量重建列表
- 位置:`services/gateway.js:820-829`(`getSnapshot` 里 `dataList.map(it => ({...it}))`,最多 200 条)+ `pages/device/detail/detail.js:190-196`(`slice().reverse().map()`,ASCII 模式下每条再跑一次 `hexToBytes`)
- 根因:收帧节流 100ms(`TIMEOUT.FLUSH_INTERVAL`),即每秒 10 次 × 200 条对象浅拷贝 × N 个订阅者,ASCII 视图下还要叠加 200 次 hex 解析。高频采集时主线程和 setData 都会明显吃紧。
- 建议:① `_notify` 合并到同一帧(`setTimeout(..., 16)` 去重,状态类更新仍可即时);② `getSnapshot` 增加 `dataListSince(id)` 增量接口,页面只 setData 新增部分;③ ASCII 转换结果缓存到 item 上(`item._ascii`),避免每次重算。
### P1-2 「我的」页在每次网关通知时同步读全量离线缓存
- 位置:`pages/mine/mine.js:165`(`checkStatus()` 内 `storage.getPendingData()`)
- 根因:`getPendingData()` 会做过期过滤 + 写回(同步 IO)。mine 页订阅了 gateway,收帧期间每 100ms 触发一次 → 每 100ms 一次全量读盘 + 可能的写盘。而 gateway 快照里**已经有 `offlineCount`**,这次读取完全多余。
- 修复:`const pendingCount = snap.offlineCount;`
### P1-3 `_upload` 无并发上限
- 位置:`services/gateway.js:596`
- 根因:自动模式下每批帧逐条 fire-and-forget 调 `_upload`,无在途数限制。MQTT 变慢时会堆积大量 QoS1 在途消息,触发底层的"在途消息过多"错误后又全部转离线缓存,形成雪崩。建议加在途计数器(如上限 20,超出直接进离线缓存)。
### P1-4 `_toOfflineItem` 可能返回 null 却被 push 进缓存数组
- 位置:`services/gateway.js:721-730` + `gateway.js:599`
- 根因:`device` 为 null 时返回 null,调用处未过滤 → 缓存里出现 null 元素 → `_flushOffline` 遍历到 `it.deviceId` 抛 TypeError,整批补传中断。改为 `const item = this._toOfflineItem(...); if (item) offline.push(item);`
### P1-5 环境配置硬编码,dev 还混搭了两个环境
- 位置:`env.config.js:7-20`
- 根因:`const ENV = 'development'` 是手改开关,发布时漏改就会把生产包指向 `http://192.168.110.117:8080`(内网 IP + 明文 HTTP)。更隐蔽的是 DEV 配置本身自相矛盾:`baseUrl` 是内网 IP,而 `mqttServer` 是公网 `47.121.180.229` —— 同一个"开发环境"里 API 和 MQTT 不在一处,真机联调极易误判"接口通了但数据上不去"。
- 建议:① 用 `wx.getAccountInfoSync().miniProgram.envVersion` 自动判定环境,彻底取消手改开关;② 生产环境必须 `https://` + `wxs://`(`project.config.json` 里 `urlCheck: true`,HTTP 域名真机必然被拦)。
### P1-6 `BLE_STATE.CONNECTED` 是永不出现的状态
- 位置:`services/ble.js:141`、`ble.js:1027`、`ble.js:1035`,`services/gateway.js:520`
- 根因:`_setState(BLE_STATE.CONNECTED)` 在整个项目里**从未被调用过**(状态机只经过 DISCONNECTED → CONNECTING → DISCOVERING → READY)。因此这 4 处的 `|| state === CONNECTED` 全是死分支,`isConnected()` 与 `isReady()` 是两份完全相同的实现。要么删掉 CONNECTED,要么在建链成功时显式置位(让"已连但未发现服务"有独立状态)。
### P1-7 超时配置双真相源
- 位置:`utils/constants.js:7-8,26` vs `services/ble.js:16-32`
- 根因:`TIMEOUT.BLE_SCAN` / `TIMEOUT.BLE_CONNECT` / `RETRY.BLE_RECONNECT_DELAY` **全项目无引用**;而 `ble.js` 的 `DEFAULT_CONFIG` 里重新硬编码了相同数值(10000 / 10000 / 重连基数 1000)。改一处不改另一处就会静默失效。
- 修复:让 `DEFAULT_CONFIG` 从 constants 取值:
```js
const { TIMEOUT, RETRY } = require('../utils/constants.js');
const DEFAULT_CONFIG = {
scanTimeout: TIMEOUT.BLE_SCAN,
connectTimeout: TIMEOUT.BLE_CONNECT,
reconnectBaseDelay: RETRY.BLE_RECONNECT_DELAY,
...
};
```
> 另外 `services/command-manager.js:6` 引入了 `LIMITS` 却从未使用,是无用 require。
### P1-8 回归测试只覆盖了两个点,主链路零覆盖
- 现状:`tests/gateway-disabled-device.test.js`(33 断言)+ `tests/detail-console-toggles.test.js`(31 断言),质量很高(含反向验证防假绿),但覆盖的是**开关语义**与**设备停用**两条边角。
- 缺口:完全没有覆盖主链路 —— 收帧 → 测点归属快照 → 自动上报 / 手动确认 / 离线缓存 → 补传。而 P0-2、P0-4、P0-5 全部出在这条链路上。建议按现有 `tests/helpers/fake-wx.js` 的可编程桩,补一套 `tests/gateway-measure-flow.test.js`(MQTT 桩可设 connected / publish hang / publish fail 三种态)。
### P1-9 `app.json` 未声明定位相关私有接口
- 位置:`app.json`
- 说明:Android 端 BLE 扫描依赖系统定位授权,且微信要求小程序在后台配置隐私协议并在 `app.json` 用 `requiredPrivateInfos` 声明所用接口。当前文件里没有相关声明,真机(尤其 Android)可能出现"扫描无任何结果且无报错"。建议核对微信后台隐私协议并补齐声明。
### P1-10 ⚡(性能类最优先)收帧链路上每 100ms 有 4~6 次同步读盘,且同一 key 被读两遍
- 位置:`services/gateway.js:572-577` → `measure-point.js:37,70` + `gateway.js:615,630`
- 根因:`_flush()` 每 100ms 执行一次,其中:
| 调用 | 位置 | 实际动作 |
| --- | --- | --- |
| `measurePoint.getCollectMode()` | `gateway.js:572` | `wx.getStorageSync` |
| `measurePoint.getMeasurePointId()` | `gateway.js:573` | `wx.getStorageSync` |
| `_resolveProductId()` | `gateway.js:576` → `615` | `getMqttConfig()` = `getStorageSync` + `decryptFields` |
| `_resolveSerialNumber()` | `gateway.js:577` → `630` | `getMqttConfig()` **再读一遍同一个 key** + 再解密一次 |
叠加 `mine.js:165` 的 `getPendingData()`(读盘 + 过期过滤 + 可能写回)与 `gateway.js:832` 的 `_refreshOfflineCount()`,收帧期间每秒约 40~60 次 `getStorageSync`。`wx.getStorageSync` 是**同步阻塞**调用,缓存量大(离线缓存上限 1000 条、设备上限 50 台)时单次可达毫秒级 —— 这正好卡在 BLE 高频收帧的关键路径上。
- 修复方向:上报上下文(productId / reportSn / 测点ID / 模式)本质是**会话级快照**,不该每次读盘。建议在 `_startNow` 时快照一次,`_flush` 直接读内存:
```js
// gateway.js
_refreshContext() {
const cfg = storage.getMqttConfig();
const dev = this._device;
this._ctx = {
productId: (cfg && cfg.productId != null && cfg.productId !== '')
? cfg.productId : (dev ? dev.productId : null),
reportSn: (cfg && cfg.serialNumber) ? cfg.serialNumber : (dev ? dev.serialNumber : null),
measurePointId: measurePoint.getMeasurePointId(),
mode: measurePoint.getCollectMode(),
};
}
// 失效时机:_startNow / resume / 「我的」页保存 MQTT 配置后 / 详情页改测点ID 后
```
> 注意:测点ID 与模式允许在采集过程中被用户修改(详情页),所以失效时机必须覆盖这两处,否则会退回"归属串点"的老问题。
### P1-11 测试桩能力缺口,恰好卡住主链路用例
- 位置:`tests/helpers/fake-wx.js:193-229`(`createFakeMqtt`)
- 根因:假 mqtt-client 的 `publish` **恒 `Promise.resolve()`**,无法模拟 hang / fail;且缺少 `allowAutoConnect()`(`app.js:101` 会调用它);`createWx` 里没有 `wx.request`。这直接导致 P1-8 建议补的主链路用例(含 P0-5 的"publish 永不结算"场景)现在写不出来 —— 桩要先行增强:
```js
// fake-wx.js: createFakeMqtt 增加
publishMode: 'ok', // 'ok' | 'hang' | 'fail'
publish: (topic, message) => {
rec.publishCalls.push({ topic, message });
if (rec.publishMode === 'hang') return new Promise(() => {}); // 永不结算
if (rec.publishMode === 'fail') return Promise.reject(new Error('发布失败'));
return Promise.resolve();
},
allowAutoConnect: () => { client.userDisconnected = false; },
```
---
## 四、P2 — 死代码与僵尸注释(建议一次性清理)
重构(product-device-measurePoint)后遗留较多,用户偏好"僵尸代码及时删"。
| 位置 | 内容 | 说明 |
| --- | --- | --- |
| `utils/auth.js:61,72,83,91,101` | `buildPropertyPostTopic` / `buildFunctionGetTopic` / `buildPropertyGetTopic` / `checkLoginStatus` / `getCurrentUserId` | 5 个导出函数全无调用方 |
| `utils/hex.js:111,137` | `crc16` / `verifyCrc16` | 无调用方(且 `verifyCrc16` 用 `===` 比大小写的 CRC,含小写输入会误判失败) |
| `utils/storage.js:45,81,159` | `removeToken` / `removeUserInfo` / `updateDevice` | 无调用方。⚠️ 其中 `updateDevice` **不加密敏感字段**(与 `addDevice`/`saveDeviceList` 不对称),一旦启用就把 productSecret 明文落盘 —— 要么删掉,要么补 `encryptFields` |
| `services/command-manager.js:112-170` | `getCommands` / `addCommand` / `updateCommand` / `removeCommand` / `incrementUseCount` / `clearHistory` | 6 个方法全无调用方;预设命令功能随 `pages/device/command` 页一起消亡 |
| `utils/command-storage.js:13-93,178` | `getCommands` / `saveCommands` / `addCommand` / `updateCommand` / `removeCommand` / `clearAll` | 同上,整层预设命令存储已死 |
| `services/command-manager.js:22` | `sendCommand(hex, method, device)` 的 `method` 参数 | 僵尸参数,永远是 `'BLE'`,函数体从不读它。建议删参数或改名为 `_`(更彻底:直接删) |
| `pages/index/index.wxml:4-11` | 注释掉的 `user-bar` 块 | 导致 `index.js:144 handleLogout` 成为死方法。要么恢复 UI,要么连方法一起删 |
| `pages/device/detail/detail.js:15-16` | 注释声称"「发送命令」页(pages/device/command)保留未删" | ❌ **该页在仓库里根本不存在**。过期注释比没注释更有害,必须更正 |
| `components/device-card/device-card.wxss:1-51` | `.swipe-wrap` / `.swipe-delete` / `.swipe-delete-text` / `.is-dragging` | 滑动删除交互已改成按钮删除,约 36 行样式残留 |
| `utils/crypto.js:9,31` + `constants.js:31` | `CRYPTO_KEY` 硬编码 | 注释自己承认"生产应从服务端动态获取"。客户端常量 key 的 XOR 只是防君子,**建议明确降级为"防明文落盘的混淆"并在注释里写清,不要给人安全错觉** |
---
## 五、P3 — 打磨项
1. `pages/device/detail/detail.js:444-451`:radio 取消用 `setData({collectMode: 0})` + `setTimeout(0)` 回拉,是绕过小程序受控组件限制的 hack。可行但脆弱,建议在注释里写明依赖的是"radio 内部选中态先于回调切换"这一未文档化行为。
2. `services/gateway.js`:`_flushOffline` 成功补传后不回写 `dataList` 里对应 item 的 `offline` 标记,UI 上"已缓存"标签会残留到下次清空。
3. `pages/device/detail/detail.js:492-499`:`handleFlushOffline` 用前后差算条数,若被 `_flushingOffline` 短路会显示"已上传 0 条",误导用户。建议用返回值条数而非快照差。
4. `pages/device/scan/scan.js:26`:`gateway.stop()` 的注释写"暂停正在跑的网关",实际是完整 teardown(phase → STOPPED)。命名与语义不符,建议改注释或提供真正的 `suspend`。
5. `services/ble.js:305`:`scanDevices` 的超时定时器无句柄,`stopScan()` 无法取消它 → 点"停止"后 10s 到点仍会回填扫描结果并覆盖 UI。建议保存 timer 并在 `stopScan` 里 clear,或给扫描加 seq 作废。
6. `pages/mine/mine.js:18`:`appVersion: '1.0.0'` 硬编码,与 `project.config.json` 无联动,发版必漏改。
7. `utils/request.js`:未设 `timeout`(默认 60s),登录流程弱网下会长时间 loading;401 分支只 reject 不做登录态收敛(与 `app.isLoggedIn()` 只看 userId 的设计一致,但两处口径应写在同一处注释里)。
8. `components/device-card/device-card.wxml:8`:启用但未连上显示"连接中",实际可能是"异常/未连接",语义偏乐观。
---
## 六、本次未验证项(需真机确认)
- 真机上 MQTT 底层 QoS1 在 ws 断连时的回调是否必然结算(决定 P0-5 是"防御性加固"还是"已发生的事故")。
- Android 端 BLE 扫描在缺少定位声明时的实际表现(P1-9)。
- 200 条 × 10 次/秒 setData 在中低端机上的实际帧率(P1-1)。

View File

@ -109,7 +109,7 @@
<block wx:if="{{displayList.length > 0}}">
<!-- 最新在后:自动滚动锚定最后一行 -->
<view class="log-line" wx:for="{{displayList}}" wx:key="id" id="log-{{item.id}}">
<text class="log-text">[{{item.time}}]: {{item.text}}</text>
<text class="log-text">[{{item.time}}]:{{item.text}}</text>
<text wx:if="{{item.uploaded}}" class="log-flag uploaded">↑已上报</text>
<text wx:elif="{{item.offline}}" class="log-flag cached">已缓存</text>
<text wx:elif="{{modeManual && !item.confirmed}}" class="log-flag confirm-btn" catchtap="onConfirmData" data-id="{{item.id}}">确认</text>

View File

@ -10,6 +10,7 @@
const ble = require('../../../services/ble.js');
const storage = require('../../../utils/storage.js');
const { gateway } = require('../../../services/gateway.js');
const { LIMITS } = require('../../../utils/constants.js');
Page({
data: {
@ -142,7 +143,17 @@ Page({
createTime: Date.now(),
};
storage.addDevice(deviceConfig);
// 必须检查返回值:数量达上限时这里是失败的,继续往下会变成
// "提示已连接、实际没保存也没连"的静默失败
if (!storage.addDevice(deviceConfig)) {
wx.hideLoading();
wx.showToast({
title: `最多添加 ${LIMITS.MAX_DEVICE_COUNT} 个设备`,
icon: 'none',
duration: 2500,
});
return;
}
// 成为唯一启用设备(单 enabled 不变量)
storage.activateDevice(serialNumber);

View File

@ -88,6 +88,15 @@ Page({
wx.showToast({ title: '请输入产品密钥', icon: 'none' });
return;
}
// productId 是上报 topic 的第 1 段,必须是正整数。
// parseInt 会把 '12.5' 截成 12、把 'abc' 变成 NaN,而网关侧的判空
// (cfg.productId == null / === '')两个都挡不住 NaN —— 会一路构造出
// /NaN/{sn}/{测点}/measure/post 这样的脏 topic 并持续上报
const pid = Number(productId);
if (!Number.isInteger(pid) || pid <= 0) {
wx.showToast({ title: '产品ID必须是正整数', icon: 'none' });
return;
}
if (!this.data.userInfo || !this.data.userInfo.userId) {
wx.showToast({ title: '请先登录', icon: 'none' });
return;
@ -95,12 +104,12 @@ Page({
this.setData({ mqttConnectingLocal: true });
try {
const creds = auth.getMqttCredentials(serialNumber, parseInt(productId), productSecret);
const creds = auth.getMqttCredentials(serialNumber, pid, productSecret);
if (!creds) {
throw new Error('生成 MQTT 凭证失败,请重新登录');
}
// 保存配置(加密 productSecret),下次进入自动回填
storage.setMqttConfig({ serialNumber, productId: parseInt(productId), productSecret });
storage.setMqttConfig({ serialNumber, productId: pid, productSecret });
await mqttClient.connect(creds);
wx.showToast({ title: 'MQTT 已连接', icon: 'success' });
} catch (err) {

View File

@ -174,10 +174,14 @@ class MqttClient {
}
// 已有客户端:先彻底终止,杜绝"幽灵客户端 + 同 clientId 互踢"
//
// ⚠️ 此处**不得**清空 subscriptions:订阅表的生命周期属于"业务意图"而非"某次连接"。
// 清空后 connect 成功事件里的 _resubscribe() 只能拿到一张空表,订阅被永久丢失;
// 而换 clientId 重连恰恰是最需要自动恢复订阅的场景。
// 唯一该清订阅的是 disconnect()(用户明确断开,不再需要这些回调)。
if (this.client) {
console.log('[MQTT] 终止旧客户端:', this.currentClientId, '->', newClientId);
this._teardownClient(this.client);
this.subscriptions.clear();
}
this._setState(MQTT_STATE.CONNECTING);
@ -324,24 +328,42 @@ class MqttClient {
* 发布消息
* QoS1 由底层等待 PUBACK 才算成功(超时会按 dup 重发,重试用尽才回调错误),
* 因此这里的成功回调代表"broker 已确认收到"
*
* ⚠️ 必须有超时出口:底层的成功/失败**完全依赖回调**,而回调要等 PUBACK 或重试用尽。
* 一旦某条消息卡在在途队列里不结算,这个 Promise 就永久 pending —— 网关的离线补传
* (_flushOffline 串行 await publish,且被 _flushingOffline 闸门保护)会被它一次性
* 锁死,之后所有补传静默失效直到重启小程序。BLE 侧每个 GATT 操作都做了超时兜底,
* 这里不能是唯一的例外。
* @param {string} topic - 主题
* @param {string} message - 消息内容
* @param {Object} options - 发布选项 {qos: 0|1|2, retain: boolean}
* @param {Object} options - 发布选项 {qos: 0|1|2, retain: boolean, publishTimeout: number}
* @returns {Promise}
*/
publish(topic, message, options = { qos: 1 }) {
const timeout = options.publishTimeout != null ? options.publishTimeout : 15000;
return new Promise((resolve, reject) => {
if (!this.client || !this.client.connected) {
reject(new Error('MQTT 未连接'));
return;
}
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
console.warn('[MQTT] 发布超时, topic:', topic);
reject(new Error('发布超时'));
}, timeout);
this.client.publish(topic, message, options, (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (err) {
console.error('[MQTT] 发布失败:', err);
reject(new Error('发布失败'));
} else {
console.log('[MQTT] 发布成功, topic:', topic, 'message:', message);
console.log('[MQTT] 发布成功, topic:', topic);
resolve();
}
});

View File

@ -9,48 +9,87 @@
const { CRYPTO_KEY } = require('./constants.js');
/**
* Base64 编码(兼容小程序环境)
* @param {string} str - 字符串
* @returns {string} Base64 编码字符串
* UTF-16 字符串 → UTF-8 字节数组
*
* 加密必须落在**字节**上:早期实现按 UTF-16 charCode 做 XOR,再用
* `DataView.setUint8` 写缓冲区 —— setUint8 只保留低 8 位,中文这类
* >255 的码点(如 '中' 0x4E2D XOR key 后仍 >255)会被静默截断,
* 解密回来是乱码,密钥被永久损坏且没有任何报错。
* 代理对(emoji)按 CESU-8 拆成两个三字节序列,编解码对称,往返无损。
* @param {string} str
* @returns {Array<number>}
*/
function base64Encode(str) {
try {
// 使用小程序提供的 ArrayBuffer 方式
const buffer = new ArrayBuffer(str.length);
const dataView = new DataView(buffer);
function utf8Bytes(str) {
const bytes = [];
for (let i = 0; i < str.length; i++) {
dataView.setUint8(i, str.charCodeAt(i));
const code = str.charCodeAt(i);
if (code < 0x80) {
bytes.push(code);
} else if (code < 0x800) {
bytes.push(0xC0 | (code >> 6), 0x80 | (code & 0x3F));
} else {
bytes.push(0xE0 | (code >> 12), 0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
}
return wx.arrayBufferToBase64(buffer);
}
return bytes;
}
/**
* UTF-8 字节数组 → UTF-16 字符串(utf8Bytes 的逆运算)
* @param {Array<number>} bytes
* @returns {string}
*/
function bytesToUtf8(bytes) {
let out = '';
for (let i = 0; i < bytes.length;) {
const b = bytes[i++];
if (b < 0x80) {
out += String.fromCharCode(b);
} else if (b < 0xE0) {
out += String.fromCharCode(((b & 0x1F) << 6) | (bytes[i++] & 0x3F));
} else {
out += String.fromCharCode(
((b & 0x0F) << 12) | ((bytes[i++] & 0x3F) << 6) | (bytes[i++] & 0x3F)
);
}
}
return out;
}
/**
* 字节数组 → Base64(兼容小程序环境)
* @param {Array<number>} bytes
* @returns {string} Base64 编码字符串;失败返回空串
*/
function base64Encode(bytes) {
try {
return wx.arrayBufferToBase64(new Uint8Array(bytes).buffer);
} catch (e) {
console.error('[Crypto] Base64 编码失败:', e);
return str;
return '';
}
}
/**
* Base64 解码(兼容小程序环境)
* Base64 → 字节数组(兼容小程序环境)
* @param {string} base64 - Base64 编码字符串
* @returns {string} 解码后的字符串
* @returns {Array<number>} 字节数组;失败返回空数组
*/
function base64Decode(base64) {
try {
// 使用小程序提供的 ArrayBuffer 方式
const buffer = wx.base64ToArrayBuffer(base64);
const bytes = new Uint8Array(buffer);
let str = '';
for (let i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i]);
}
return str;
return Array.from(new Uint8Array(buffer));
} catch (e) {
console.error('[Crypto] Base64 解码失败:', e);
return base64;
return [];
}
}
/**
* 简单对称加密(XOR + Base64)
* 对称加密(XOR + Base64),按字节运算,任意字符均可无损往返
*
* 失败一律返回空串,**不 fail-open 返回明文** —— 把明文当密文落盘
* 比"加密失败"危险得多(后者是可感知的,前者只是悄悄不加密)。
* @param {string} text - 明文
* @param {string} key - 密钥
* @returns {string} 密文(Base64 编码)
@ -58,17 +97,17 @@ function base64Decode(base64) {
function encrypt(text, key = CRYPTO_KEY) {
if (!text) return '';
try {
// XOR 加密
let encrypted = '';
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i) ^ key.charCodeAt(i % key.length);
encrypted += String.fromCharCode(charCode);
const bytes = utf8Bytes(text);
const keyBytes = utf8Bytes(key);
if (keyBytes.length === 0) {
console.error('[Crypto] 密钥为空,拒绝加密');
return '';
}
// Base64 编码
const encrypted = bytes.map((b, i) => b ^ keyBytes[i % keyBytes.length]);
return base64Encode(encrypted);
} catch (e) {
console.error('[Crypto] 加密失败:', e);
return text;
return '';
}
}
@ -76,23 +115,18 @@ function encrypt(text, key = CRYPTO_KEY) {
* 解密
* @param {string} ciphertext - 密文(Base64 编码)
* @param {string} key - 密钥
* @returns {string} 明文
* @returns {string} 明文;失败返回空串(同样不返回密文冒充明文)
*/
function decrypt(ciphertext, key = CRYPTO_KEY) {
if (!ciphertext) return '';
try {
// Base64 解码
const encrypted = base64Decode(ciphertext);
// XOR 解密(与加密过程相同)
let decrypted = '';
for (let i = 0; i < encrypted.length; i++) {
const charCode = encrypted.charCodeAt(i) ^ key.charCodeAt(i % key.length);
decrypted += String.fromCharCode(charCode);
}
return decrypted;
const bytes = base64Decode(ciphertext);
const keyBytes = utf8Bytes(key);
if (bytes.length === 0 || keyBytes.length === 0) return '';
return bytesToUtf8(bytes.map((b, i) => b ^ keyBytes[i % keyBytes.length]));
} catch (e) {
console.error('[Crypto] 解密失败:', e);
return ciphertext;
return '';
}
}

View File

@ -117,7 +117,13 @@ function saveDeviceList(devices) {
/**
* 添加设备(加密敏感字段)
*
* 返回值必须被检查:设备数量达上限时是**失败**,调用方若不看返回值继续走
* "激活 → 取回 → 启动采集",会得到"提示已连接、实际既没保存也没连"的假象
* (scan 页曾因此静默吞掉一次添加)。
* 工具层不弹 toast:UI 呈现交给调用方,避免"上限提示"与"连接成功"两个 toast 打架。
* @param {Object} device
* @returns {boolean} 是否写入成功
*/
function addDevice(device) {
const devices = getDeviceList();
@ -131,15 +137,12 @@ function addDevice(device) {
// 检查设备数量限制
if (devices.length >= LIMITS.MAX_DEVICE_COUNT) {
console.warn('[Storage] 设备数量已达上限:', LIMITS.MAX_DEVICE_COUNT);
wx.showToast({
title: `最多添加 ${LIMITS.MAX_DEVICE_COUNT} 个设备`,
icon: 'none',
});
return;
return false;
}
devices.push(encryptedDevice);
}
saveDeviceList(devices);
return true;
}
/**

View File

@ -0,0 +1,247 @@
/**
* 回归:本轮 code review 修掉的 5 个 P0 缺陷
*
* 每条都锁死"修复后的语义",并尽可能给出**反向证明**(展示修复前的写法会产出什么),
* 防止将来有人"顺手简化"时把缺陷放回去。
*
* 红线:
* 1. crypto 必须按**字节**做 XOR(UTF-8),非 ASCII 无损往返;失败不 fail-open
* 2. addDevice 必须返回布尔,数量达上限时返回 false(调用方据此中断)
* 3. publish 必须有超时出口,底层永不回调时也能结算
* 4. subscriptions 的生命周期属于"业务意图",只有 disconnect() 能清
* 5. 产品ID 必须是正整数,NaN / 小数不得进入 topic
*
* 跑法:node tests/p0-regressions.test.js
*/
const fs = require('fs');
const path = require('path');
const { MINI_ROOT, loadRuntime, clearMiniCache } = require('./helpers/fake-wx.js');
/* ============================ 迷你断言器 ============================ */
let passed = 0;
let failed = 0;
function ok(cond, label, extra) {
if (cond) {
passed++;
process.stdout.write(` ✓ ${label}\n`);
} else {
failed++;
process.stdout.write(` ✗ ${label}${extra !== undefined ? ` ← ${JSON.stringify(extra)}` : ''}\n`);
}
}
function eq(actual, expected, label) {
ok(actual === expected, `${label} (期望 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)})`);
}
/* ============================ 加载真实 MQTT 模块 ============================ */
/**
* ⚠️ 必须在 loadRuntime() 之前调用:loadRuntime 会用假 mqtt-client 顶掉
* services/mqtt-client.js,之后 require 拿到的是桩而不是被测实现
*/
function loadRealMqtt() {
clearMiniCache();
global.wx = {
getNetworkType: (o = {}) => setTimeout(() => o.success && o.success({ networkType: 'wifi' }), 0),
arrayBufferToBase64: () => '',
base64ToArrayBuffer: () => new ArrayBuffer(0),
};
return require(path.join(MINI_ROOT, 'services/mqtt-client.js'));
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/* ============================ 用例 ============================ */
/** P0-5:publish 超时出口 */
async function casePublishTimeout() {
process.stdout.write('\n[1] P0-5 publish 必须有超时出口(底层永不回调也能结算)\n');
const { mqttClient } = loadRealMqtt();
// 坏桩:publish 永不回调,模拟卡在在途队列里的 QoS1 消息
mqttClient.client = { connected: true, publish: () => {} };
const t0 = Date.now();
let msg = null;
try {
await mqttClient.publish('/1/BT_01/001/measure/post', 'AABB', { publishTimeout: 120 });
} catch (err) {
msg = err.message;
}
const cost = Date.now() - t0;
eq(msg, '发布超时', '超时后 reject');
ok(cost >= 110 && cost < 1000, `约 120ms 内结算,不是永久挂起 (实际 ${cost}ms)`);
// 未连接仍应立刻拒绝,不进超时路径
mqttClient.client = null;
let msg2 = null;
try { await mqttClient.publish('/t', 'x'); } catch (err) { msg2 = err.message; }
eq(msg2, 'MQTT 未连接', '未连接时立即 reject');
// 成功路径不受影响
mqttClient.client = { connected: true, publish: (t, m, o, cb) => cb && cb(null) };
let resolved = false;
await mqttClient.publish('/t', 'x').then(() => { resolved = true; }).catch(() => {});
ok(resolved, '正常回调时仍然 resolve');
}
/** P0-4:订阅表不得随连接被清空 */
async function caseSubscriptions() {
process.stdout.write('\n[2] P0-4 订阅表生命周期属于业务意图,只有 disconnect() 能清\n');
const { mqttClient } = loadRealMqtt();
mqttClient.subscriptions.set('/p/1/sn/function/get', () => {});
mqttClient.client = { end: () => {} };
mqttClient._teardownClient(mqttClient.client);
eq(mqttClient.subscriptions.size, 1, '拆掉客户端后订阅表仍在(供 _resubscribe 恢复)');
const SRC = fs.readFileSync(path.join(MINI_ROOT, 'services/mqtt-client.js'), 'utf8');
const clears = SRC.match(/this\.subscriptions\.clear\(\)/g) || [];
eq(clears.length, 1, 'subscriptions.clear() 全文件只允许 1 处');
ok(SRC.indexOf('this.subscriptions.clear()') > SRC.indexOf(' disconnect()'),
'唯一那处在 disconnect() 内(用户主动断开才清)');
ok(SRC.includes('_resubscribe'), 'connect 成功后仍有 _resubscribe 恢复订阅');
}
/** P0-3:crypto 字节级编解码 */
async function caseCrypto() {
process.stdout.write('\n[3] P0-3 crypto 按字节做 XOR,非 ASCII 无损往返\n');
const rt = loadRuntime();
const { encrypt, decrypt, encryptFields, decryptFields } = require(path.join(MINI_ROOT, 'utils/crypto.js'));
const KEY = 'fastbee-mini-2026';
// 修复前的写法(按 UTF-16 charCode XOR + setUint8 截断),用于兼容性与反向证明
const oldEncrypt = (text, key = KEY) => {
let s = '';
for (let i = 0; i < text.length; i++) s += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
const bytes = [];
for (let i = 0; i < s.length; i++) bytes.push(s.charCodeAt(i) & 0xFF);
return rt.wx.arrayBufferToBase64(new Uint8Array(bytes).buffer);
};
// 存量兼容:纯 ASCII 的新密文必须与旧实现逐字节一致,老数据才能继续解开
for (const s of ['secret', 'fastbee', 'Abc123!@#', '0123456789abcdefXYZ']) {
eq(encrypt(s), oldEncrypt(s), `新密文与旧实现一致(存量可解): ${s}`);
eq(decrypt(oldEncrypt(s)), s, `新实现能解开旧密文: ${s}`);
}
// 非 ASCII:修复前会静默损坏,修复后必须无损
for (const s of ['密钥中文', '密码abc中文', '测点·2026', '🔧扳手', 'Ünïcödé']) {
eq(decrypt(encrypt(s)), s, `往返无损: ${s}`);
}
ok(decrypt(oldEncrypt('密钥中文')) !== '密钥中文',
'反向证明:旧实现对中文确实不可逆损坏', decrypt(oldEncrypt('密钥中文')));
// 不得 fail-open(旧实现异常时返回明文/密文本身)
eq(encrypt(''), '', 'encrypt("") 返回空串');
eq(decrypt(''), '', 'decrypt("") 返回空串');
const realDecode = global.wx.base64ToArrayBuffer;
global.wx.base64ToArrayBuffer = () => { throw new Error('decode boom'); };
eq(decrypt('anycipher'), '', '解码异常时返回空串,不拿密文冒充明文');
global.wx.base64ToArrayBuffer = realDecode;
// 字段级
const enc = encryptFields({ productSecret: '我的密钥123', serialNumber: 'BT_01' }, ['productSecret']);
ok(enc.productSecret !== '我的密钥123' && enc._productSecret_encrypted === true, '敏感字段已加密并打标记');
eq(enc.serialNumber, 'BT_01', '非敏感字段不受影响');
const dec = decryptFields(enc, ['productSecret']);
eq(dec.productSecret, '我的密钥123', '字段级往返无损');
eq(dec._productSecret_encrypted, undefined, '解密后清除标记');
}
/** P0-2:addDevice 返回值 */
async function caseAddDevice() {
process.stdout.write('\n[4] P0-2 addDevice 数量达上限必须返回 false(供调用方中断)\n');
const rt = loadRuntime();
let added = 0;
for (let i = 0; i < 60; i++) {
const sn = 'DEV_' + String(i).padStart(3, '0');
if (rt.storage.addDevice({ deviceId: 'ID_' + i, serialNumber: sn, name: sn, enabled: false }) === true) added++;
}
eq(added, 50, '恰好写入 50 台后开始返回 false');
eq(rt.storage.addDevice({ serialNumber: 'DEV_OVER', deviceId: 'X' }), false, '第 51 台返回 false');
eq(rt.storage.getDeviceList().length, 50, '列表未被超限写入污染');
eq(rt.storage.addDevice({ serialNumber: 'DEV_000', deviceId: 'ID_0', name: '改名' }), true, '覆盖既有编号仍返回 true');
// 调用方确实检查了返回值:scan.js 里 addDevice 之后不得无条件继续激活+启动
const SCAN = fs.readFileSync(path.join(MINI_ROOT, 'pages/device/scan/scan.js'), 'utf8');
ok(/if\s*\(\s*!storage\.addDevice\(/.test(SCAN), 'scan.js 对 addDevice 的返回值做了失败分支');
ok(!/^\s*storage\.addDevice\(deviceConfig\);\s*$/m.test(SCAN), '不再有「无视返回值直接调用」的写法');
}
/** P0-1:产品ID 必须是正整数 */
async function caseProductId() {
process.stdout.write('\n[5] P0-1 产品ID 必须校验为整数,NaN/小数不得进入 topic\n');
const rt = loadRuntime();
// 必须种进 storage:auth.getMqttCredentials 读的是存储里的用户信息,不是页面 data。
// 不种的话合法用例会走到"未登录"分支而失败,非法用例也会因提前 return 而假绿
rt.storage.setUserInfo({ userId: 1, userName: 'tester' });
const toasts = [];
global.wx.showToast = (o) => toasts.push(o.title);
global.wx.showModal = (o) => toasts.push('MODAL:' + o.title);
global.wx.showLoading = () => {};
global.wx.hideLoading = () => {};
global.getApp = () => ({ globalData: {} });
let cfg = null;
global.Page = (c) => { cfg = c; };
require(path.join(MINI_ROOT, 'pages/mine/mine.js'));
if (!cfg) throw new Error('mine.js 未调用 Page(),加载失败');
const page = Object.assign({}, cfg);
page.data = JSON.parse(JSON.stringify(cfg.data));
page.setData = function (patch) { Object.assign(this.data, patch); };
page.setData({ userInfo: { userId: 1 } });
const auth = require(path.join(MINI_ROOT, 'utils/auth.js'));
// 反向证明:修复前的 parseInt 会产出 NaN,而网关的判空两道都挡不住它
eq(auth.buildMeasurePostTopic(parseInt('abc'), 'BT_01', '001'), '/NaN/BT_01/001/measure/post',
'反向证明:parseInt("abc") 会造出 /NaN/ 脏 topic');
eq(auth.buildMeasurePostTopic(parseInt('12.5'), 'BT_01', '001'), '/12/BT_01/001/measure/post',
'反向证明:parseInt("12.5") 会静默截断成 12');
for (const bad of ['abc', '12.5', '0', '-3', '']) {
rt.storage.removeMqttConfig();
toasts.length = 0;
page.setData({ mqttConnectingLocal: false });
page.data.mqttForm = { serialNumber: 'BT_01', productId: bad, productSecret: 'sec' };
await page.handleMqttConnect();
eq(rt.storage.getMqttConfig(), null, `非法产品ID "${bad}" 不落盘`);
}
// 合法输入仍正常
rt.storage.removeMqttConfig();
toasts.length = 0;
page.setData({ mqttConnectingLocal: false });
page.data.mqttForm = { serialNumber: 'BT_01', productId: '12', productSecret: 'sec' };
await page.handleMqttConnect();
const saved = rt.storage.getMqttConfig();
ok(saved !== null, '合法产品ID 正常保存');
eq(saved && saved.productId, 12, '保存为数字 12(不是字符串)');
eq(auth.buildMeasurePostTopic(saved.productId, saved.serialNumber, '001'), '/12/BT_01/001/measure/post',
'构造出的 topic 正确');
}
/* ============================ 执行 ============================ */
(async () => {
process.stdout.write('\n===== P0 缺陷回归 =====\n');
// 顺序敏感:[1][2] 必须先加载真实 mqtt-client,之后 loadRuntime 才会注入假模块
await casePublishTimeout();
await caseSubscriptions();
await caseCrypto();
await caseAddDevice();
await caseProductId();
process.stdout.write(`\n===== 通过 ${passed} / 失败 ${failed} =====\n`);
process.exit(failed === 0 ? 0 : 1);
})();

View File

@ -1,101 +0,0 @@
/**
* 详情页接收行/发送行 视觉回归校验(headless Chrome)
*
* 做法:直接读取真实 detail.wxss(rpx→px 换算) + 按 detail.wxml 结构生成 DOM,
* 并把小程序基础库的 button 默认样式(含 wx-button:not([size=mini]){width:184px})
* 一起注入 → 得到的布局与真机同源,可直接量出「上传离线是否被挤出屏幕」。
*
* 用法:node tools/detail-layout-check.js (生成 /tmp/detail-layout.html)
*/
const fs = require('fs');
const path = require('path');
const BASE = path.join(__dirname, '../miniprogram');
const DEVICE_W = 390; // iPhone 12/13/14 逻辑宽
const RATIO = DEVICE_W / 750; // 1rpx -> px (0.52)
// 1) 真实 wxss:rpx → px(保持其余原样,含选择器与特性权重)
const rawCss = fs.readFileSync(path.join(BASE, 'pages/device/detail/detail.wxss'), 'utf8');
const css = rawCss.replace(/(-?\d*\.?\d+)rpx/g, (_, n) => `${(parseFloat(n) * RATIO).toFixed(3)}px`);
// 2) 基础库 button 默认样式(从 WAWebview.js 原样抄,选择器 wx-button → button 等价替换)
const BASE_CSS = `
button{-webkit-tap-highlight-color:transparent;background-color:#f8f8f8;border-radius:5px;box-sizing:border-box;color:#000;cursor:pointer;display:block;font-size:18px;line-height:2.55555556;overflow:hidden;padding-left:14px;padding-right:14px;position:relative;text-align:center;text-decoration:none;border:none}
button:not([size=mini]){margin-left:auto;margin-right:auto;width:184px}
button::after{content:" ";position:absolute;top:0;left:0;width:200%;height:200%;border:1px solid rgba(0,0,0,.2);border-radius:10px;box-sizing:border-box;transform:scale(.5);transform-origin:0 0}
button[disabled]:not([type]){background-color:#fafafa;color:rgba(0,0,0,.18)}
`;
// 3) 按 detail.wxml 结构写 DOM(含 {{}} 取值)
const DATA = {
device: { serialNumber: 'BT_ECB0D1002F84', deviceId: 'EC:B0:D1:00:2F:84', enabled: true },
bleConnected: true, mqttConnected: true,
canRetryBle: false, canRetryMqtt: false,
displayList: [], stats: { received: 0, uploaded: 1, failed: 0 },
offlineCount: 128, autoScroll: true, recvHex: true,
logFollowing: true, lastError: null,
measurePointId: '371883', measurePointIdValid: true,
collectMode: 1, modeManual: true, canFlush: true, canSend: true, sendHex: true, sendInput: '',
};
const ev = (expr) => { try { return new Function('d', `with(d){return (${expr})}`)(DATA); } catch { return `?${expr}`; } };
const bind = (s) => s.replace(/\{\{([^}]+)\}\}/g, (_, e) => ev(e.trim()));
const html = `<!doctype html><html><head><meta charset="utf-8"><style>
${BASE_CSS}
/* ===== 真实 detail.wxss(rpx→px) ===== */
${css}
html,body{margin:0;padding:0}
</style></head><body>
<div class="page" style="width:${DEVICE_W}px;height:1000px">
<div class="body-scroll">
<div class="device-info card">
<div class="info-row"><span class="info-label">设备编号</span><span class="info-value">${bind('{{device.serialNumber}}')}</span></div>
<div class="info-row"><span class="info-label">BLE 状态</span>
<span class="status-tag ${bind("{{bleConnected ? 'online' : 'offline'}}")} ${bind("{{canRetryBle ? 'retryable' : ''}}")}">${bind("{{bleConnected ? '已连接' : (canRetryBle ? '未连接 · 点击重连' : '未连接')}}")}</span></div>
<div class="info-row"><span class="info-label">MQTT 状态</span>
<span class="status-tag ${bind("{{mqttConnected ? 'online' : 'offline'}}")} ${bind("{{canRetryMqtt ? 'retryable' : ''}}")}">${bind("{{mqttConnected ? '已连接' : (canRetryMqtt ? '未连接 · 点击重连' : '未连接')}}")}</span></div>
</div>
</div>
<div class="console">
<div class="console-header">
<span class="console-title">数据接收:</span>
<div class="console-tools" id="tools">
<button class="btn-clear-mini" id="b-clear">清空</button>
<label class="chk-item"><span class="chk-box">✓</span><span>滚动</span></label>
<label class="chk-item"><span class="chk-box">✓</span><span>Hex</span></label>
<button class="btn-flush ${bind("{{canFlush ? '' : 'is-disabled'}}")}" id="b-flush" ${DATA.canFlush ? '' : 'disabled'}>上传离线(${DATA.offlineCount})</button>
</div>
</div>
<div class="log-wrap"><div class="log-scroll"><div class="log-empty">等待设备上报数据…</div></div></div>
<div class="console-stats"><span class="stat-chip">接收 0</span><span class="stat-chip">已上报 1</span><span class="stat-chip">失败 0</span><span class="stat-chip">离线 ${DATA.offlineCount}</span></div>
<div class="console-header send-header"><span class="console-title">数据发送:</span>
<label class="chk-item"><span class="chk-box">✓</span><span>Hex</span></label></div>
<div class="send-row">
<input class="send-input" placeholder="如:00 60 00 FF 00 FF F0">
<button class="btn-send ${bind("{{canSend ? '' : 'is-disabled'}}")}" id="b-send">发送</button>
</div>
</div>
</div>
<script>
const W = ${DEVICE_W}, PAD = ${(20 * RATIO).toFixed(3)}; // console padding 20rpx
const avail = W - PAD * 2;
const r = (id) => document.getElementById(id).getBoundingClientRect();
const f = (n) => Math.round(n * 10) / 10;
const flush = r('b-flush'), tools = r('tools'), clear = r('b-clear'), send = r('b-send'), input = document.querySelector('.send-input').getBoundingClientRect();
const overflow = tools.width > avail + 0.5;
const rows = [
'可用宽度 ' + f(avail) + 'px(390 - 20rpx*2)',
'接收行工具区 ' + f(tools.width) + 'px ' + (overflow ? '❌ 溢出 ' + f(tools.width - avail) + 'px' : '✅ 未溢出,余量 ' + f(avail - tools.width) + 'px'),
' 清空 ' + f(clear.width) + 'px | 上传离线(' + ${DATA.offlineCount} + ') ' + f(flush.width) + 'px 右边界 ' + f(flush.right) + ' / 容器右界 ' + f(W - PAD),
' 上传离线是否完整可见:' + (flush.right <= W - PAD + 0.5 ? '✅ 是' : '❌ 否(被挤出 ' + f(flush.right - (W - PAD)) + 'px)'),
'发送行 输入框 ' + f(input.width) + 'px | 发送按钮 ' + f(send.width) + 'px',
'接收行高度 ' + f(r('tools').height) + 'px',
].join('\\n');
const out = document.createElement('pre');
out.id = 'result'; out.style.cssText = 'font:11px/1.6 monospace;background:#111;color:#0f0;margin:0;padding:6px 8px;white-space:pre-wrap';
out.textContent = rows; document.body.insertBefore(out, document.body.firstChild);
</script></body></html>`;
const outPath = '/tmp/detail-layout.html';
fs.writeFileSync(outPath, html);
console.log('written:', outPath);

View File

@ -54,6 +54,7 @@
</el-button>
</el-input>
</el-form-item>
<!-- 基准点先删掉 -->
<!-- 基准点设备:相对测量类设备(静力水准等)需指定基准点,留空表示绝对量测量。不做 deviceType 限制,任意子设备均可配置 -->
<el-form-item prop="baselineDeviceId">
<template slot="label">