MINI:优化小程序P0问题
This commit is contained in:
parent
5738dfb9e3
commit
6a803952a8
@ -175,11 +175,10 @@ public class ToolController extends BaseController {
|
||||
if (mqttConfig.getUsername().equals(username) && mqttConfig.getPassword().equals(password)) {
|
||||
log.info("-----------服务端mqtt认证成功,clientId:{}---------------", clientid);
|
||||
ret.put("result", "allow");
|
||||
return ResponseEntity.ok().body(ret);
|
||||
} else {
|
||||
ret.put("result", "deny");
|
||||
return ResponseEntity.ok().body(ret);
|
||||
}
|
||||
return ResponseEntity.ok().body(ret);
|
||||
} else if (clientid.startsWith("web") || clientid.startsWith("phone")) {
|
||||
// web端和移动端认证:token认证
|
||||
String token = password;
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
package com.fastbee.iot.model;
|
||||
|
||||
|
||||
import com.fastbee.common.annotation.Excel;
|
||||
|
||||
public class ProductAuthenticateModel {
|
||||
/**
|
||||
* 产品分类ID
|
||||
@ -59,6 +57,9 @@ public class ProductAuthenticateModel {
|
||||
*/
|
||||
private String mqttSecret;
|
||||
|
||||
/**
|
||||
* 1=简单认证,2=加密认证,3=简单+加密认证
|
||||
*/
|
||||
private int vertificateMethod;
|
||||
|
||||
public int getVertificateMethod() {
|
||||
|
||||
@ -310,7 +310,7 @@ public class ToolServiceImpl implements IToolService {
|
||||
if (productModel.getIsAuthorize() == 1) {
|
||||
// 授权码验证和处理
|
||||
String resultMessage = authCodeProcess(authCode, mqttModel, productModel);
|
||||
if (!resultMessage.equals("")) {
|
||||
if (!resultMessage.isEmpty()) {
|
||||
return returnUnauthorized(mqttModel, resultMessage);
|
||||
}
|
||||
}
|
||||
@ -318,13 +318,13 @@ public class ToolServiceImpl implements IToolService {
|
||||
if (productModel.getStatus() == 2) {
|
||||
return returnUnauthorized(mqttModel, "设备简单认证,设备处于禁用状态");
|
||||
}
|
||||
log.info("-----------设备简单认证成功,clientId:" + mqttModel.getClientId() + "---------------");
|
||||
log.info("-----------设备简单认证成功,clientId:{}---------------", mqttModel.getClientId());
|
||||
return ResponseEntity.ok().body("ok");
|
||||
} else {
|
||||
// 自动添加设备
|
||||
int result = deviceService.insertDeviceAuto(mqttModel.getDeviceNumber(), mqttModel.getUserId(), mqttModel.getProductId());
|
||||
if (result == 1) {
|
||||
log.info("-----------设备简单认证成功,并自动添加设备到系统,clientId:" + mqttModel.getClientId() + "---------------");
|
||||
log.info("-----------设备简单认证成功,并自动添加设备到系统,clientId:{}---------------", mqttModel.getClientId());
|
||||
return ResponseEntity.ok().body("ok");
|
||||
}
|
||||
return returnUnauthorized(mqttModel, "设备简单认证,自动添加设备失败");
|
||||
@ -343,7 +343,7 @@ public class ToolServiceImpl implements IToolService {
|
||||
return returnUnauthorized(mqttModel, "设备加密认证,设备对应产品不支持加密认证");
|
||||
}
|
||||
String decryptPassword = AESUtils.decrypt(mqttModel.getPassword(), productModel.getMqttSecret());
|
||||
if (decryptPassword == null || decryptPassword.equals("")) {
|
||||
if (decryptPassword == null || decryptPassword.isEmpty()) {
|
||||
return returnUnauthorized(mqttModel, "设备加密认证,mqtt密码解密失败");
|
||||
}
|
||||
String[] passwordArray = decryptPassword.split("&");
|
||||
@ -352,7 +352,7 @@ public class ToolServiceImpl implements IToolService {
|
||||
return returnUnauthorized(mqttModel, "设备加密认证,mqtt密码加密格式为:密码 & 过期时间 & 授权码,其中授权码为可选");
|
||||
}
|
||||
String mqttPassword = passwordArray[0];
|
||||
Long expireTime = Long.valueOf(passwordArray[1]);
|
||||
long expireTime = Long.parseLong(passwordArray[1]);
|
||||
String authCode = passwordArray.length == 3 ? passwordArray[2] : "";
|
||||
// 验证用户名
|
||||
if (!mqttModel.getUserName().equals(productModel.getMqttAccount())) {
|
||||
@ -370,7 +370,7 @@ public class ToolServiceImpl implements IToolService {
|
||||
if (productModel.getIsAuthorize() == 1) {
|
||||
// 授权码验证和处理
|
||||
String resultMessage = authCodeProcess(authCode, mqttModel, productModel);
|
||||
if (!resultMessage.equals("")) {
|
||||
if (!resultMessage.isEmpty()) {
|
||||
return returnUnauthorized(mqttModel, resultMessage);
|
||||
}
|
||||
}
|
||||
@ -379,13 +379,13 @@ public class ToolServiceImpl implements IToolService {
|
||||
if (productModel.getStatus() == 2) {
|
||||
return returnUnauthorized(mqttModel, "设备加密认证,设备处于禁用状态");
|
||||
}
|
||||
log.info("-----------设备加密认证成功,clientId:" + mqttModel.getClientId() + "---------------");
|
||||
log.info("-----------设备加密认证成功,clientId:{}---------------", mqttModel.getClientId());
|
||||
return ResponseEntity.ok().body("ok");
|
||||
} else {
|
||||
// 自动添加设备
|
||||
int result = deviceService.insertDeviceAuto(mqttModel.getDeviceNumber(), mqttModel.getUserId(), mqttModel.getProductId());
|
||||
if (result == 1) {
|
||||
log.info("-----------设备加密认证成功,并自动添加设备到系统,clientId:" + mqttModel.getClientId() + "---------------");
|
||||
log.info("-----------设备加密认证成功,并自动添加设备到系统,clientId:{}---------------", mqttModel.getClientId());
|
||||
return ResponseEntity.ok().body("ok");
|
||||
}
|
||||
return returnUnauthorized(mqttModel, "设备加密认证,自动添加设备失败");
|
||||
@ -434,8 +434,8 @@ public class ToolServiceImpl implements IToolService {
|
||||
ret.put("is_superuser", false);
|
||||
// 设备端认证:加密认证(E)和简单认证(S,配置的账号密码认证)
|
||||
String[] clientArray = clientid.split("&");
|
||||
if (clientArray.length != 4 || clientArray[0].equals("") || clientArray[1].equals("") || clientArray[2].equals("") || clientArray[3].equals("")) {
|
||||
return this.returnUnauthorized(new MqttAuthenticationModel(clientid, username, password), "设备mqtt客户端Id格式为:认证类型 & 设备编号 & 产品ID & 用户ID");
|
||||
if (clientArray.length != 4 || clientArray[0].isEmpty() || clientArray[1].isEmpty() || clientArray[2].isEmpty() || clientArray[3].isEmpty()) {
|
||||
return this.returnUnauthorized(new MqttAuthenticationModel(clientid, username, password), "设备MQTT客户端Id格式为:认证类型 & 设备编号 & 产品ID & 用户ID");
|
||||
}
|
||||
String authType = clientArray[0];
|
||||
String deviceNumber = clientArray[1];
|
||||
@ -444,13 +444,13 @@ public class ToolServiceImpl implements IToolService {
|
||||
// 产品认证信息
|
||||
ProductAuthenticateModel model = deviceService.selectProductAuthenticate(new AuthenticateInputModel(deviceNumber, productId));
|
||||
if (model == null) {
|
||||
log.error("-----------设备认证,通过产品ID查询不到信息,clientId:" + clientid + "---------------");
|
||||
log.error("-----------设备认证,通过产品ID查询不到信息,clientId:{}---------------", clientid);
|
||||
ret.put("result", "deny");
|
||||
return ResponseEntity.ok().body(ret);
|
||||
}
|
||||
if (model.getProductStatus() != 2) {
|
||||
// 产品必须为发布状态:1-未发布,2-已发布
|
||||
log.error("-----------设备认证,设备对应产品还未发布,clientId:" + clientid + "---------------");
|
||||
log.error("-----------设备认证,设备对应产品还未发布,clientId:{}---------------", clientid);
|
||||
ret.put("result", "deny");
|
||||
return ResponseEntity.ok().body(ret);
|
||||
}
|
||||
@ -460,24 +460,22 @@ public class ToolServiceImpl implements IToolService {
|
||||
ResponseEntity res = this.simpleMqttAuthentication(new MqttAuthenticationModel(clientid, username, password, deviceNumber, productId, userId), model);
|
||||
if (res.getStatusCodeValue() == HttpStatus.OK.value()) {
|
||||
ret.put("result", "allow");
|
||||
return ResponseEntity.ok().body(ret);
|
||||
} else {
|
||||
ret.put("result", "deny");
|
||||
return ResponseEntity.ok().body(ret);
|
||||
}
|
||||
return ResponseEntity.ok().body(ret);
|
||||
|
||||
} else if (authType.equals("E")) {
|
||||
// 设备加密认证
|
||||
ResponseEntity res = this.encryptAuthentication(new MqttAuthenticationModel(clientid, username, password, deviceNumber, productId, userId), model);
|
||||
if (res.getStatusCodeValue() == HttpStatus.OK.value()) {
|
||||
ret.put("result", "allow");
|
||||
return ResponseEntity.ok().body(ret);
|
||||
} else {
|
||||
ret.put("result", "deny");
|
||||
return ResponseEntity.ok().body(ret);
|
||||
}
|
||||
return ResponseEntity.ok().body(ret);
|
||||
} else {
|
||||
log.error("-----------设备认证,认证类型有误,clientId:" + clientid + "---------------");
|
||||
log.error("-----------设备认证,认证类型有误,clientId:{}---------------", clientid);
|
||||
ret.put("result", "deny");
|
||||
return ResponseEntity.ok().body(ret);
|
||||
}
|
||||
@ -488,8 +486,8 @@ public class ToolServiceImpl implements IToolService {
|
||||
*/
|
||||
private String authCodeProcess(String authCode, MqttAuthenticationModel mqttModel, ProductAuthenticateModel productModel) {
|
||||
String message = "";
|
||||
if (authCode.equals("")) {
|
||||
return message = "设备认证,设备授权码不能为空";
|
||||
if (authCode.isEmpty()) {
|
||||
return "设备认证,设备授权码不能为空";
|
||||
}
|
||||
// 查询授权码是否存在
|
||||
ProductAuthorize authorize = productAuthorizeMapper.selectFirstAuthorizeByAuthorizeCode(new ProductAuthorize(authCode, productModel.getProductId()));
|
||||
|
||||
@ -13,10 +13,10 @@ const DEV_CONFIG = {
|
||||
// 生产环境
|
||||
const PROD_CONFIG = {
|
||||
baseUrl: 'https://ycdat.cn/prod-api',
|
||||
mqttServer: 'wxs://ycdat.cn/mqtt',
|
||||
mqttServer: 'wss://ycdat.cn/mqtt',
|
||||
};
|
||||
|
||||
// 当前环境:development | production
|
||||
const ENV = 'development';
|
||||
// 当前环境:development wxede2cc5d39b2c6ea | production wx8bcff4708d1f6d2b
|
||||
const ENV = 'production';
|
||||
|
||||
module.exports = ENV === 'development' ? DEV_CONFIG : PROD_CONFIG;
|
||||
@ -38,16 +38,16 @@
|
||||
<view class="status-section">
|
||||
<view class="section-title">MQTT 连接</view>
|
||||
<view class="mqtt-form">
|
||||
<view class="mqtt-item">
|
||||
<text class="mqtt-label">设备编号(SN)</text>
|
||||
<input class="mqtt-input" placeholder="客户端ID依据" value="{{mqttForm.serialNumber}}" bindinput="onMqttInput" data-field="serialNumber" />
|
||||
</view>
|
||||
<view class="mqtt-item">
|
||||
<text class="mqtt-label">产品ID</text>
|
||||
<input class="mqtt-input" type="number" placeholder="产品ID" value="{{mqttForm.productId}}" bindinput="onMqttInput" data-field="productId" />
|
||||
</view>
|
||||
<view class="mqtt-item">
|
||||
<text class="mqtt-label">产品密钥</text>
|
||||
<text class="mqtt-label">产品Mqtt账号</text>
|
||||
<input class="mqtt-input" placeholder="产品Mqtt账号" value="{{mqttForm.serialNumber}}" bindinput="onMqttInput" data-field="serialNumber" />
|
||||
</view>
|
||||
<view class="mqtt-item">
|
||||
<text class="mqtt-label">产品Mqtt密码</text>
|
||||
<input class="mqtt-input" password placeholder="产品MQTT密码" value="{{mqttForm.productSecret}}" bindinput="onMqttInput" data-field="productSecret" />
|
||||
</view>
|
||||
<view class="mqtt-actions">
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "3.3.4",
|
||||
"appid": "wxede2cc5d39b2c6ea",
|
||||
"appid": "wx8bcff4708d1f6d2b",
|
||||
"projectname": "yack-mini",
|
||||
"condition": {},
|
||||
"editorSetting": {
|
||||
|
||||
@ -1,205 +0,0 @@
|
||||
/**
|
||||
* 回归:设备详情页底部收发控制台的三个开关(滚动 / 接收Hex / 发送Hex)
|
||||
*
|
||||
* 缺陷(修复前):
|
||||
* 三处都是「裸 checkbox + bindchange」——而 bindchange 是 checkbox-group 的事件,
|
||||
* checkbox 自身不提供(官方属性表只有 value/disabled/checked/color)。
|
||||
* handler 又按 group 的数组语义写成 `e.detail.value.length > 0`:
|
||||
* 布尔值下 `true.length === undefined` → `undefined > 0 === false`,三个开关恒定失效。
|
||||
* 现场表现:取消「接收Hex」切不到 ASCII(曾被误判成 asciiOfHex 显示「·」的设计行为)、
|
||||
* 「滚动」关不掉也开不了、发送区一直按 hex 语义走。
|
||||
*
|
||||
* 红线(本套用例锁死):
|
||||
* 1. wxml 结构:每个 checkbox 必须在 checkbox-group 内,group 各绑一个 change
|
||||
* 2. detail.js 不得回退成 `e.detail.value.length > 0` 这种数组语义误用
|
||||
* 3. group 派发 value 数组时,三个开关能正确切换 data
|
||||
* 4. 取消「接收Hex」→ 日志切成 ASCII 视图;勾上 → 回到 hex 视图(用最近快照重渲染)
|
||||
* 5. 取消「滚动」→ autoScroll 与 logFollowing 同时关闭;勾上 → 同时恢复
|
||||
*
|
||||
* 跑法:node tests/detail-console-toggles.test.js
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { MINI_ROOT, loadRuntime } = 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)})`);
|
||||
}
|
||||
|
||||
/* ============================ 被测文件与页面实例 ============================ */
|
||||
|
||||
const DETAIL_DIR = path.join(MINI_ROOT, 'pages/device/detail');
|
||||
const WXML = fs.readFileSync(path.join(DETAIL_DIR, 'detail.wxml'), 'utf8');
|
||||
const DETAIL_JS = fs.readFileSync(path.join(DETAIL_DIR, 'detail.js'), 'utf8');
|
||||
const DETAIL_SRC = path.join(DETAIL_DIR, 'detail.js');
|
||||
|
||||
/**
|
||||
* 每次调用都重建一套干净的运行时 + 全新的页面实例,
|
||||
* 用例之间不共享 data,避免"上一条用例把开关改过"造成的假绿/假红
|
||||
*/
|
||||
function loadPage() {
|
||||
const rt = loadRuntime(); // 内部 clearMiniCache + 重建 global.wx
|
||||
global.getApp = () => ({ globalData: {} });
|
||||
let cfg = null;
|
||||
global.Page = (c) => { cfg = c; };
|
||||
require(DETAIL_SRC);
|
||||
if (!cfg) throw new Error('detail.js 未调用 Page(),加载失败');
|
||||
|
||||
const page = Object.assign({}, cfg);
|
||||
page.data = JSON.parse(JSON.stringify(cfg.data));
|
||||
page.setData = function (patch, cb) {
|
||||
Object.assign(this.data, patch);
|
||||
if (cb) cb();
|
||||
};
|
||||
return { rt, page };
|
||||
}
|
||||
|
||||
/* ============================ 用例 ============================ */
|
||||
|
||||
/** 结构红线:勾选框必须被 checkbox-group 包着 */
|
||||
function case1_wxmlStructure() {
|
||||
process.stdout.write('\n[1] wxml 结构:checkbox 必须在 checkbox-group 内\n');
|
||||
|
||||
const groups = WXML.match(/<checkbox-group[^>]*>[\s\S]*?<\/checkbox-group>/g) || [];
|
||||
eq(groups.length, 3, '三个开关各有一个 checkbox-group');
|
||||
eq((WXML.match(/<checkbox[\s>]/g) || []).length, 3, '页面共 3 个 checkbox');
|
||||
|
||||
ok(!/<checkbox\s[^>]*bindchange/.test(WXML),
|
||||
'没有任何「裸 checkbox 直接挂 bindchange」的写法(这是本次缺陷的形态)');
|
||||
|
||||
ok(groups.some((g) => g.includes('bindchange="onAutoScrollToggle"') && g.includes('value="scroll"')),
|
||||
'「滚动」group 绑 onAutoScrollToggle,子项 value=scroll');
|
||||
ok(groups.some((g) => g.includes('bindchange="onRecvHexToggle"') && g.includes('value="hex"')),
|
||||
'接收「Hex」group 绑 onRecvHexToggle,子项 value=hex');
|
||||
ok(groups.some((g) => g.includes('bindchange="onSendHexToggle"') && g.includes('value="hex"')),
|
||||
'发送「Hex」group 绑 onSendHexToggle,子项 value=hex');
|
||||
}
|
||||
|
||||
/** 代码红线:不得回退成数组语义误用 */
|
||||
function case2_codeGuards() {
|
||||
process.stdout.write('\n[2] detail.js 取值方式:读 group 的 value 数组,而非误判为布尔\n');
|
||||
|
||||
ok(!/\.detail\.value\.length\s*>\s*0/.test(DETAIL_JS),
|
||||
'没有 `e.detail.value.length > 0` 的残留(该写法在布尔值下恒 false)');
|
||||
|
||||
ok(/onAutoScrollToggle\(e\)\s*\{[^}]*includes\('scroll'\)/.test(DETAIL_JS),
|
||||
'onAutoScrollToggle 用 includes(\'scroll\') 读值');
|
||||
ok(/onRecvHexToggle\(e\)\s*\{[^}]*includes\('hex'\)/.test(DETAIL_JS),
|
||||
'onRecvHexToggle 用 includes(\'hex\') 读值');
|
||||
ok(/onSendHexToggle\(e\)\s*\{[^}]*includes\('hex'\)/.test(DETAIL_JS),
|
||||
'onSendHexToggle 用 includes(\'hex\') 读值');
|
||||
|
||||
// 反例固化:裸 checkbox 若派发布尔值,旧写法必然恒 false —— 本次缺陷的数学根因
|
||||
const legacy = (v) => v.length > 0;
|
||||
eq(legacy(true), false, '反例:布尔 true 在旧写法下取到 false');
|
||||
eq(legacy(false), false, '反例:布尔 false 在旧写法下取到 false');
|
||||
}
|
||||
|
||||
/** 滚动开关:勾选/取消都要同时管住 autoScroll 与 logFollowing */
|
||||
function case3_autoScrollToggle() {
|
||||
process.stdout.write('\n[3] 「滚动」开关\n');
|
||||
const { page } = loadPage();
|
||||
|
||||
eq(page.data.autoScroll, true, '初始勾选(默认自动跟随)');
|
||||
eq(page.data.logFollowing, true, '初始处于跟随态');
|
||||
|
||||
page.onAutoScrollToggle({ detail: { value: [] } }); // 用户取消勾选
|
||||
eq(page.data.autoScroll, false, '取消勾选 → 停止自动滚动');
|
||||
eq(page.data.logFollowing, false, '取消勾选 → 同时退出跟随态');
|
||||
|
||||
page.onAutoScrollToggle({ detail: { value: ['scroll'] } }); // 用户重新勾选
|
||||
eq(page.data.autoScroll, true, '重新勾选 → 恢复自动滚动');
|
||||
eq(page.data.logFollowing, true, '重新勾选 → 恢复跟随态');
|
||||
}
|
||||
|
||||
/** 接收 Hex 开关:切换 data 并用最近快照重渲染日志视图 */
|
||||
function case4_recvHexToggle() {
|
||||
process.stdout.write('\n[4] 接收「Hex」开关(含视图重渲染)\n');
|
||||
const { page } = loadPage();
|
||||
|
||||
// 造一份最近快照:gateway 的 dataList 最新在前
|
||||
page.data.device = { serialNumber: 'BT_1' };
|
||||
page._lastSnap = {
|
||||
device: { serialNumber: 'BT_1' },
|
||||
dataList: [
|
||||
{ id: '2', hex: '41424344' }, // 最新一帧
|
||||
{ id: '1', hex: '31323334' },
|
||||
],
|
||||
};
|
||||
|
||||
eq(page.data.recvHex, true, '初始勾选(hex 视图)');
|
||||
|
||||
page.onRecvHexToggle({ detail: { value: [] } }); // 取消勾选 → ASCII 视图
|
||||
eq(page.data.recvHex, false, '取消勾选 → 切到 ASCII 视图');
|
||||
eq(page.data.displayList.length, 2, 'ASCII 视图:用最近快照重渲染出 2 行');
|
||||
eq(page.data.displayList[1].text, 'ABCD', '最新在后:末行为最新帧的 ASCII 文本');
|
||||
eq(page.data.displayList[0].text, '1234', 'ASCII 视图:可打印字节原样显示');
|
||||
|
||||
page.onRecvHexToggle({ detail: { value: ['hex'] } }); // 勾上 → 回 hex 视图
|
||||
eq(page.data.recvHex, true, '勾选 → 回到 hex 视图');
|
||||
eq(page.data.displayList[1].text, '41424344', 'hex 视图:末行为原始 hex 帧');
|
||||
|
||||
// 非本设备的快照不得上屏
|
||||
page._lastSnap = { device: { serialNumber: 'BT_OTHER' }, dataList: [{ id: '9', hex: 'AA' }] };
|
||||
page.onRecvHexToggle({ detail: { value: [] } });
|
||||
eq(page.data.displayList.length, 0, '非本设备快照:不上屏(保持空列表)');
|
||||
}
|
||||
|
||||
/** 发送 Hex 开关:切换语义 + 已有输入大小写归一 */
|
||||
function case5_sendHexToggle() {
|
||||
process.stdout.write('\n[5] 发送「Hex」开关\n');
|
||||
const { page } = loadPage();
|
||||
|
||||
eq(page.data.sendHex, true, '初始勾选(按 hex 字节发送)');
|
||||
|
||||
page.data.sendInput = 'at+csq';
|
||||
page.onSendHexToggle({ detail: { value: [] } }); // 取消 → ASCII 文本发送
|
||||
eq(page.data.sendHex, false, '取消勾选 → 按 ASCII 文本发送');
|
||||
eq(page.data.sendInput, 'at+csq', 'ASCII 模式保留原文大小写(大小写是内容)');
|
||||
|
||||
page.data.sendInput = 'at+csq';
|
||||
page.onSendHexToggle({ detail: { value: ['hex'] } }); // 勾上 → hex 语义
|
||||
eq(page.data.sendHex, true, '勾选 → 按 hex 字节发送');
|
||||
eq(page.data.sendInput, 'AT+CSQ', 'hex 模式统一大写(便于阅读)');
|
||||
}
|
||||
|
||||
/* ============================ 主流程 ============================ */
|
||||
|
||||
function main() {
|
||||
process.stdout.write('===== 详情页收发控制台开关 回归 =====\n');
|
||||
const cases = [
|
||||
case1_wxmlStructure,
|
||||
case2_codeGuards,
|
||||
case3_autoScrollToggle,
|
||||
case4_recvHexToggle,
|
||||
case5_sendHexToggle,
|
||||
];
|
||||
for (const c of cases) {
|
||||
try {
|
||||
c();
|
||||
} catch (e) {
|
||||
failed++;
|
||||
process.stdout.write(` ✗ 用例抛异常: ${e && e.stack}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`\n===== 详情页收发控制台开关:通过 ${passed} / 失败 ${failed} =====\n`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
@ -1,329 +0,0 @@
|
||||
/**
|
||||
* 回归:设备开关关闭后不得再尝试连接蓝牙
|
||||
*
|
||||
* 现场日志(复现源):
|
||||
* [Storage] 禁用设备: BT_ECB0D1002F84
|
||||
* [BLE] 连接超时: EC:B0:D1:00:2F:84
|
||||
* [BLE] 连续 3 次建链失败,复位适配器 ← 替一个已经关掉的设备复位整个蓝牙栈
|
||||
* [GW] 启动失败: Error: 连接失败: 连接超时。 ← 还把失败上屏 + 排 5s 自动重试
|
||||
*
|
||||
* 红线(本套用例锁死):
|
||||
* 1. 设备开关关闭后,蓝牙**不再发起任何建链尝试**(不建链、不排自动重试、不复位适配器)
|
||||
* 2. 关闭动作必须**立即生效**,不能等队列里在途的 _boot(最坏一次建链超时 + 适配器复位)
|
||||
* 3. MQTT 是应用级连接,**不受设备开关影响**(防止有人把 MQTT 也绑进设备开关)
|
||||
*
|
||||
* 跑法:node tests/gateway-disabled-device.test.js
|
||||
*/
|
||||
const {
|
||||
loadRuntime, seedScene, sleep,
|
||||
} = require('./helpers/fake-wx.js');
|
||||
|
||||
/* ============================ 迷你断言器 ============================ */
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
/** 安全的展示值(_reconnectTimer 是 Timeout 对象,直接 JSON.stringify 会因循环引用抛错) */
|
||||
function safe(v) {
|
||||
if (v === undefined) return 'undefined';
|
||||
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
||||
try {
|
||||
return JSON.stringify(v);
|
||||
} catch (e) {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
function ok(cond, label, extra) {
|
||||
if (cond) {
|
||||
passed++;
|
||||
process.stdout.write(` ✓ ${label}\n`);
|
||||
} else {
|
||||
failed++;
|
||||
process.stdout.write(` ✗ ${label}${extra !== undefined ? ` ← ${safe(extra)}` : ''}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual, expected, label) {
|
||||
ok(actual === expected, `${label} (期望 ${safe(expected)},实际 ${safe(actual)})`);
|
||||
}
|
||||
|
||||
/* ============================ 用例 ============================ */
|
||||
|
||||
const DEV = { serialNumber: 'BT_ECB0D1002F84', deviceId: 'EC:B0:D1:00:2F:84' };
|
||||
|
||||
/** 关掉 console 噪音,只留我们自己写的结果 */
|
||||
function muteConsole() {
|
||||
const raw = { log: console.log, warn: console.warn, error: console.error };
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
console.error = () => {};
|
||||
return () => Object.assign(console, raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用例 1:无启用设备时,不碰蓝牙;MQTT 照旧(应用级)
|
||||
*/
|
||||
async function case1_noEnabledDevice() {
|
||||
process.stdout.write('\n[1] 无启用设备 → 不碰蓝牙,MQTT 仍按应用级自动补连\n');
|
||||
const restore = muteConsole();
|
||||
const rt = loadRuntime();
|
||||
seedScene(rt, { ...DEV, enabled: false });
|
||||
rt.bleService.setConfig({ connectTimeout: 150, opTimeout: 150, adapterOpTimeout: 120, closeWaitTimeout: 60 });
|
||||
|
||||
await rt.gateway.ensureStarted();
|
||||
await rt.gateway.resume();
|
||||
await sleep(120);
|
||||
restore();
|
||||
|
||||
eq(rt.state.calls.create.length, 0, '没有发起任何建链');
|
||||
eq(rt.state.calls.openAdapter, 0, '没有打开蓝牙适配器');
|
||||
eq(rt.gateway.getSnapshot().phase, 'stopped', '相位收敛到 stopped');
|
||||
ok(rt.mqtt._rec.connectCalls.length > 0, 'MQTT 仍被自动补连(应用级,不受设备开关影响)', rt.mqtt._rec.connectCalls);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用例 2:运行中关闭设备开关(等价首页 index.handleDeviceSwitch 关闭分支)
|
||||
* → 立即中止在途建链,不复位适配器,不上屏错误,不排自动重试
|
||||
*/
|
||||
async function case2_disableWhileConnecting() {
|
||||
process.stdout.write('\n[2] 采集中关闭设备 → 立即中止在途建链 / 不复位适配器 / 不上屏 / 不重试\n');
|
||||
const restore = muteConsole();
|
||||
const rt = loadRuntime({ createMode: 'hang' }); // 建链永不回调:模拟"连接超时"现场
|
||||
seedScene(rt, DEV);
|
||||
rt.bleService.setConfig({ connectTimeout: 150, opTimeout: 150, adapterOpTimeout: 120, closeWaitTimeout: 60 });
|
||||
// 已经连续失败 2 次:再来 1 次就够 3 次阈值 → 差一点就要"复位适配器"。
|
||||
// 这正是现场日志里那一步;关闭设备后它必须被跳过
|
||||
rt.bleService._consecutiveCreateFails = 2;
|
||||
|
||||
const boot = rt.gateway.ensureStarted(); // 首页 onShow / 启用设备后的启动
|
||||
await sleep(40);
|
||||
eq(rt.state.calls.create.length, 1, '建链已在途(1 次)');
|
||||
|
||||
// 记录整条状态轨迹:关闭设备后**任何时刻**都不该出现"异常/启动失败",
|
||||
// 这样断言与"收尾拆除"的先后无关(看单次快照会被 teardown 清干净而假绿)
|
||||
const trace = [];
|
||||
const unsub = rt.gateway.subscribe((s) => trace.push({ phase: s.phase, lastError: s.lastError }));
|
||||
|
||||
// === 用户按下设备开关的关闭 ==(index.handleDeviceSwitch)
|
||||
rt.storage.deactivateDevice(DEV.serialNumber);
|
||||
const stop = rt.gateway.ensureStarted();
|
||||
|
||||
await Promise.allSettled([boot, stop]);
|
||||
await sleep(120);
|
||||
const snap = rt.gateway.getSnapshot();
|
||||
const createAfter = rt.state.calls.create.length;
|
||||
const closeConnAfter = rt.state.calls.closeConn.length;
|
||||
const adapterResets = rt.state.calls.closeAdapter;
|
||||
// 观察窗口:远超建链超时,任何新的自动重连/失败重试都会冒头
|
||||
await sleep(500);
|
||||
const createSettled = rt.state.calls.create.length;
|
||||
unsub();
|
||||
restore();
|
||||
|
||||
ok(closeConnAfter >= 1, '关闭时立即拆链(closeBLEConnection 已调用)', closeConnAfter);
|
||||
eq(adapterResets, 0, '不复位蓝牙适配器(作废流程不得升级为全局复位)');
|
||||
eq(createSettled, createAfter, '关闭后窗口内没有新增建链尝试');
|
||||
ok(!trace.some((t) => t.phase === 'error'), '状态轨迹里从未出现 error 相位', trace);
|
||||
ok(trace.every((t) => t.lastError === null), '状态轨迹里从未上屏"启动失败"', trace);
|
||||
eq(snap.lastError, null, '终态无残留错误');
|
||||
eq(rt.gateway._initRetryTimer, null, '没有排下"启动失败自动重试"');
|
||||
eq(rt.gateway._device, null, '网关已无采集目标');
|
||||
eq(snap.phase, 'stopped', '相位收敛到 stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* 用例 3:bleService 隔离 —— 作废的流程抛 cancelled,且不升级为"复位适配器"
|
||||
*/
|
||||
async function case3_obsoleteFlowNoEscalate() {
|
||||
process.stdout.write('\n[3] bleService:作废流程抛 cancelled,不复位适配器\n');
|
||||
const restore = muteConsole();
|
||||
const rt = loadRuntime({ createMode: 'hang' });
|
||||
rt.bleService.setConfig({ connectTimeout: 120, opTimeout: 120, adapterOpTimeout: 100, closeWaitTimeout: 50 });
|
||||
await rt.bleService.initBluetooth();
|
||||
const opensAfterInit = rt.state.calls.openAdapter;
|
||||
rt.bleService._consecutiveCreateFails = 2;
|
||||
|
||||
const p = rt.bleService.connectDevice(DEV.deviceId);
|
||||
await sleep(30);
|
||||
await rt.bleService.disconnect(); // 用户关闭设备 → 作废在途流程
|
||||
const err = await p.then(() => null, (e) => e);
|
||||
await sleep(160);
|
||||
restore();
|
||||
|
||||
ok(err && err.cancelled === true, '在途流程以 cancelled 结算(上层按"不算失败"处理)', err && err.message);
|
||||
eq(rt.state.calls.closeAdapter, 0, '没有复位适配器');
|
||||
eq(rt.state.calls.openAdapter, opensAfterInit, '没有重新打开适配器');
|
||||
eq(rt.bleService.getState(), 'disconnected', '状态为 disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* 用例 4:resetAdapter 必须"复原"手动断开意图,而不是无条件清零
|
||||
*/
|
||||
async function case4_resetAdapterKeepsIntent() {
|
||||
process.stdout.write('\n[4] resetAdapter 复原 _manuallyDisconnected(不得抹掉用户意图)\n');
|
||||
const restore = muteConsole();
|
||||
const rt = loadRuntime();
|
||||
rt.bleService.setConfig({ adapterOpTimeout: 100 });
|
||||
|
||||
rt.bleService._manuallyDisconnected = true; // 场景:设备已被关闭
|
||||
await rt.bleService.resetAdapter();
|
||||
eq(rt.bleService._manuallyDisconnected, true, '复位后仍保持"已停用/主动断开"意图');
|
||||
|
||||
rt.bleService._manuallyDisconnected = false; // 场景:正常自愈重连中复位
|
||||
await rt.bleService.resetAdapter();
|
||||
eq(rt.bleService._manuallyDisconnected, false, '正常复位后不残留 true(否则进入死态)');
|
||||
restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 用例 5:主动断开后,自动重连不得把链路拉回来
|
||||
*/
|
||||
async function case5_noReconnectAfterDisconnect() {
|
||||
process.stdout.write('\n[5] 断开后不排自动重连(入口 + 定时器触发双重把关)\n');
|
||||
const restore = muteConsole();
|
||||
const rt = loadRuntime();
|
||||
rt.bleService.setConfig({ connectTimeout: 120, opTimeout: 120 });
|
||||
|
||||
await rt.bleService.initBluetooth();
|
||||
await rt.bleService.disconnect();
|
||||
rt.bleService._scheduleReconnect();
|
||||
eq(rt.bleService._reconnectTimer, null, '主动断开后 _scheduleReconnect 不排定时器');
|
||||
|
||||
// 反过来:允许重连时排下的定时器,在触发前用户关了设备 → 到点也不得重连
|
||||
rt.bleService._lastDeviceId = DEV.deviceId;
|
||||
rt.bleService._manuallyDisconnected = false;
|
||||
rt.bleService._reconnectAttempts = 0;
|
||||
rt.bleService._scheduleReconnect(); // 1s 后触发
|
||||
await rt.bleService.disconnect(); // 触发前关闭设备
|
||||
const before = rt.state.calls.create.length;
|
||||
await sleep(1300);
|
||||
const after = rt.state.calls.create.length;
|
||||
restore();
|
||||
|
||||
eq(after, before, '定时器到点后没有重连');
|
||||
}
|
||||
|
||||
/**
|
||||
* 用例 6:重新开启设备后一切照旧(防止"关掉就再也连不上"的修过头)
|
||||
*/
|
||||
async function case6_reenableWorks() {
|
||||
process.stdout.write('\n[6] 重新开启设备 → 正常建链并进入采集中\n');
|
||||
const restore = muteConsole();
|
||||
const rt = loadRuntime({ createMode: 'ok' });
|
||||
seedScene(rt, DEV);
|
||||
rt.bleService.setConfig({ connectTimeout: 500, opTimeout: 500, adapterOpTimeout: 300, closeWaitTimeout: 100 });
|
||||
|
||||
const boot = rt.gateway.ensureStarted();
|
||||
await sleep(60);
|
||||
eq(rt.state.calls.create.length, 1, '建链 1 次');
|
||||
rt.storage.deactivateDevice(DEV.serialNumber);
|
||||
await rt.gateway.ensureStarted();
|
||||
await boot.catch(() => {});
|
||||
await sleep(80);
|
||||
eq(rt.gateway.getSnapshot().phase, 'stopped', '关闭后停止');
|
||||
|
||||
rt.storage.activateDevice(DEV.serialNumber);
|
||||
await rt.gateway.ensureStarted();
|
||||
await sleep(150);
|
||||
const snap = rt.gateway.getSnapshot();
|
||||
restore();
|
||||
|
||||
ok(rt.state.calls.create.length >= 2, '重新开启后再次建链', rt.state.calls.create.length);
|
||||
eq(snap.bleConnected, true, 'BLE 已就绪');
|
||||
eq(snap.phase, 'running', '相位 running(采集中)');
|
||||
eq(snap.lastError, null, '无残留错误');
|
||||
}
|
||||
|
||||
/**
|
||||
* 用例 7:gateway.start(已停用的设备) 既不建链,也不连累当前正在跑的采集
|
||||
*/
|
||||
async function case7_startDisabledDeviceIsIgnored() {
|
||||
process.stdout.write('\n[7] start(已停用设备) → 不建链且不打断当前采集\n');
|
||||
const restore = muteConsole();
|
||||
const rt = loadRuntime({ createMode: 'ok' });
|
||||
seedScene(rt, DEV);
|
||||
rt.bleService.setConfig({ connectTimeout: 500, opTimeout: 500, adapterOpTimeout: 300, closeWaitTimeout: 100 });
|
||||
|
||||
await rt.gateway.ensureStarted();
|
||||
await sleep(150);
|
||||
eq(rt.gateway.getSnapshot().phase, 'running', '设备 A 采集中');
|
||||
|
||||
const closeConnBefore = rt.state.calls.closeConn.length;
|
||||
await rt.gateway.start({
|
||||
serialNumber: 'BT_OTHER_DEVICE',
|
||||
deviceId: 'AA:BB:CC:DD:EE:FF',
|
||||
enabled: false,
|
||||
});
|
||||
await sleep(80);
|
||||
const snap = rt.gateway.getSnapshot();
|
||||
restore();
|
||||
|
||||
ok(!rt.state.calls.create.includes('AA:BB:CC:DD:EE:FF'), '没有替已停用设备建链', rt.state.calls.create);
|
||||
eq(rt.state.calls.closeConn.length, closeConnBefore, '没有拆掉当前设备的链路');
|
||||
eq(snap.phase, 'running', '当前采集不受影响');
|
||||
eq(snap.device && snap.device.serialNumber, DEV.serialNumber, '目标仍是设备 A');
|
||||
}
|
||||
|
||||
/**
|
||||
* 用例 8:直接切换设备(A 开 → 开 B,A 自动失活)不得被同步中止逻辑弄坏
|
||||
* 单 enabled 不变量下开 B 等于关 A,_syncTarget 会立即拆 A 的链
|
||||
*/
|
||||
async function case8_switchDeviceStillWorks() {
|
||||
process.stdout.write('\n[8] 切换到另一台设备(原设备自动失活)仍能正常接管\n');
|
||||
const restore = muteConsole();
|
||||
const rt = loadRuntime({ createMode: 'ok' });
|
||||
seedScene(rt, DEV);
|
||||
rt.bleService.setConfig({ connectTimeout: 500, opTimeout: 500, adapterOpTimeout: 300, closeWaitTimeout: 100 });
|
||||
|
||||
await rt.gateway.ensureStarted();
|
||||
await sleep(120);
|
||||
eq(rt.gateway.getSnapshot().phase, 'running', '设备 A 采集中');
|
||||
|
||||
const B = { serialNumber: 'BT_AAAABBBBCCCC', deviceId: 'AA:AA:BB:BB:CC:CC', name: 'B' };
|
||||
rt.storage.addDevice({ ...B, bleName: B.serialNumber, enabled: false, createTime: Date.now() });
|
||||
rt.storage.activateDevice(B.serialNumber); // 开 B = 关 A
|
||||
await rt.gateway.ensureStarted();
|
||||
await sleep(200);
|
||||
const snap = rt.gateway.getSnapshot();
|
||||
const adapterResets = rt.state.calls.closeAdapter;
|
||||
const creates = rt.state.calls.create.slice();
|
||||
restore();
|
||||
|
||||
eq(rt.storage.getEnabledDevice().serialNumber, B.serialNumber, 'B 成为唯一启用设备');
|
||||
eq(rt.storage.getDevice(DEV.serialNumber).enabled, false, 'A 已失活');
|
||||
ok(creates.includes(B.deviceId), '已为 B 建链', creates);
|
||||
eq(snap.device && snap.device.serialNumber, B.serialNumber, '网关目标切到 B');
|
||||
eq(snap.bleConnected, true, 'B 的 BLE 已就绪');
|
||||
eq(snap.phase, 'running', '采集中');
|
||||
eq(adapterResets, 0, '切换设备不需要复位适配器');
|
||||
eq(snap.lastError, null, '切换过程无错误上屏');
|
||||
}
|
||||
|
||||
/* ============================ 主流程 ============================ */
|
||||
|
||||
async function main() {
|
||||
const cases = [
|
||||
case1_noEnabledDevice,
|
||||
case2_disableWhileConnecting,
|
||||
case3_obsoleteFlowNoEscalate,
|
||||
case4_resetAdapterKeepsIntent,
|
||||
case5_noReconnectAfterDisconnect,
|
||||
case6_reenableWorks,
|
||||
case7_startDisabledDeviceIsIgnored,
|
||||
case8_switchDeviceStillWorks,
|
||||
];
|
||||
for (const c of cases) {
|
||||
try {
|
||||
await c();
|
||||
} catch (e) {
|
||||
failed++;
|
||||
process.stdout.write(` ✗ 用例抛异常: ${e && e.stack}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`\n===== 设备开关关闭后不再连蓝牙:通过 ${passed} / 失败 ${failed} =====\n`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
@ -1,307 +0,0 @@
|
||||
/**
|
||||
* 假 wx 运行时(供 yack-mini 纯 JS 模块的 Node 回归测试使用)
|
||||
*
|
||||
* 设计要点(踩过的坑,勿改回去):
|
||||
* - **storage 必须深拷贝**:真机 wx.setStorageSync 是序列化存储,直接存对象引用会让
|
||||
* "读列表 → 改字段 → 存回"与内存里的对象互为别名,测出假的通过。
|
||||
* - **native 桩必须能"失败"与"永不回调"**:被测状态机的分支几乎都由失败码驱动,
|
||||
* 只回 success 会让一半用例假绿(链接不存在时要回 10002、适配器未开时要回 10001)。
|
||||
* - **异步回调**:wx 的回调都是异步的,同步触发会打乱"先注册监听再连接"的顺序。
|
||||
* - 只暴露被测代码真正会用到的 API,**新增 wx.onXxx 用法时只改这一个文件**。
|
||||
*/
|
||||
const path = require('path');
|
||||
|
||||
const MINI_ROOT = path.resolve(__dirname, '../../miniprogram');
|
||||
|
||||
/* ============================ 存储 ============================ */
|
||||
|
||||
function createStorageArea() {
|
||||
const map = new Map();
|
||||
const clone = (v) => (v === undefined ? v : JSON.parse(JSON.stringify(v)));
|
||||
return {
|
||||
_map: map,
|
||||
getStorageSync: (k) => (map.has(k) ? clone(map.get(k)) : ''),
|
||||
setStorageSync: (k, v) => { map.set(k, clone(v)); },
|
||||
removeStorageSync: (k) => { map.delete(k); },
|
||||
clearStorageSync: () => map.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================ BLE 桩 ============================ */
|
||||
|
||||
// 一个非通用(非 1800/1801/180a/180f)服务,带 1 个 notify + 1 个 write 特征,
|
||||
// 让 _discoverServices/_discoverCharacteristics 能走到 READY
|
||||
const SVC = '0000FFF0-0000-1000-8000-00805F9B34FB';
|
||||
const CHAR_NOTIFY = '0000FFF1-0000-1000-8000-00805F9B34FB';
|
||||
const CHAR_WRITE = '0000FFF2-0000-1000-8000-00805F9B34FB';
|
||||
|
||||
function createBleState() {
|
||||
return {
|
||||
adapterOpen: false,
|
||||
links: new Set(), // 已建立的链路 deviceId(createBLEConnection 成功才加入)
|
||||
createMode: 'ok', // 'ok' | 'hang'(native 永不回调)| 'fail'
|
||||
createErrMsg: '连接超时',
|
||||
listeners: { conn: [], char: [], adapter: [] },
|
||||
calls: {
|
||||
openAdapter: 0, closeAdapter: 0,
|
||||
create: [], closeConn: [], mtu: [], services: [], chars: [], notify: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createBleApi(st) {
|
||||
const emitConn = (deviceId, connected) => {
|
||||
st.listeners.conn.forEach((cb) => cb({ deviceId, connected }));
|
||||
};
|
||||
|
||||
return {
|
||||
onBLEConnectionStateChange: (cb) => st.listeners.conn.push(cb),
|
||||
onBLECharacteristicValueChange: (cb) => st.listeners.char.push(cb),
|
||||
onBluetoothAdapterStateChange: (cb) => st.listeners.adapter.push(cb),
|
||||
|
||||
openBluetoothAdapter: (o = {}) => {
|
||||
st.calls.openAdapter++;
|
||||
st.adapterOpen = true;
|
||||
setTimeout(() => o.success && o.success({}), 0);
|
||||
},
|
||||
|
||||
closeBluetoothAdapter: (o = {}) => {
|
||||
st.calls.closeAdapter++;
|
||||
st.adapterOpen = false;
|
||||
// native 关适配器会同时拆掉全部链路并派发断链事件(实测如此)
|
||||
const ids = Array.from(st.links);
|
||||
st.links.clear();
|
||||
setTimeout(() => {
|
||||
ids.forEach((id) => emitConn(id, false));
|
||||
o.success && o.success({});
|
||||
o.complete && o.complete({});
|
||||
}, 0);
|
||||
},
|
||||
|
||||
createBLEConnection: (o = {}) => {
|
||||
st.calls.create.push(o.deviceId);
|
||||
if (st.createMode === 'hang') return; // 坏桩:永不回调(测超时兜底)
|
||||
setTimeout(() => {
|
||||
if (st.createMode === 'fail') {
|
||||
o.fail && o.fail({ errCode: 10003, errMsg: st.createErrMsg });
|
||||
return;
|
||||
}
|
||||
st.links.add(o.deviceId);
|
||||
o.success && o.success({});
|
||||
}, 0);
|
||||
},
|
||||
|
||||
closeBLEConnection: (o = {}) => {
|
||||
st.calls.closeConn.push(o.deviceId);
|
||||
const had = st.links.has(o.deviceId);
|
||||
setTimeout(() => {
|
||||
if (!had) {
|
||||
// 链路本就不存在:真机回 fail 且**不派发** onBLEConnectionStateChange
|
||||
o.fail && o.fail({ errCode: 10002, errMsg: 'no connection' });
|
||||
o.complete && o.complete({});
|
||||
return;
|
||||
}
|
||||
st.links.delete(o.deviceId);
|
||||
emitConn(o.deviceId, false);
|
||||
o.success && o.success({});
|
||||
o.complete && o.complete({});
|
||||
}, 0);
|
||||
},
|
||||
|
||||
setBLEMTU: (o = {}) => {
|
||||
st.calls.mtu.push(o.deviceId);
|
||||
setTimeout(() => { o.success && o.success({}); o.complete && o.complete({}); }, 0);
|
||||
},
|
||||
|
||||
getBLEDeviceServices: (o = {}) => {
|
||||
st.calls.services.push(o.deviceId);
|
||||
setTimeout(() => {
|
||||
if (!st.links.has(o.deviceId)) {
|
||||
// 链路不存在时必须失败,否则"GATT 探活"永远返回 alive(死链发现不了)
|
||||
o.fail && o.fail({ errCode: 10002, errMsg: 'no connection' });
|
||||
return;
|
||||
}
|
||||
o.success && o.success({
|
||||
services: [
|
||||
{ uuid: '00001800-0000-1000-8000-00805F9B34FB', isPrimary: true },
|
||||
{ uuid: SVC, isPrimary: true },
|
||||
],
|
||||
});
|
||||
}, 0);
|
||||
},
|
||||
|
||||
getBLEDeviceCharacteristics: (o = {}) => {
|
||||
st.calls.chars.push(o.characteristicId || o.serviceId);
|
||||
setTimeout(() => {
|
||||
if (!st.links.has(o.deviceId)) {
|
||||
o.fail && o.fail({ errCode: 10002, errMsg: 'no connection' });
|
||||
return;
|
||||
}
|
||||
o.success && o.success({
|
||||
characteristics: [
|
||||
{ uuid: CHAR_NOTIFY, properties: { notify: true, read: true } },
|
||||
{ uuid: CHAR_WRITE, properties: { write: true, writeNoResponse: true } },
|
||||
],
|
||||
});
|
||||
}, 0);
|
||||
},
|
||||
|
||||
notifyBLECharacteristicValueChange: (o = {}) => {
|
||||
st.calls.notify.push(o.characteristicId);
|
||||
setTimeout(() => o.success && o.success({}), 0);
|
||||
},
|
||||
|
||||
getSystemInfoSync: () => ({ platform: 'android' }),
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================ wx 组装 ============================ */
|
||||
|
||||
function createWx(storageArea, bleState) {
|
||||
return {
|
||||
...createBleApi(bleState),
|
||||
...storageArea,
|
||||
arrayBufferToBase64: (buf) => Buffer.from(new Uint8Array(buf)).toString('base64'),
|
||||
base64ToArrayBuffer: (b64) => {
|
||||
const buf = Buffer.from(b64, 'base64');
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
||||
},
|
||||
getNetworkType: (o = {}) => setTimeout(() => o.success && o.success({ networkType: 'wifi' }), 0),
|
||||
onNetworkStatusChange: () => {},
|
||||
showToast: () => {},
|
||||
showModal: () => {},
|
||||
showLoading: () => {},
|
||||
hideLoading: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================ 模块加载 ============================ */
|
||||
|
||||
function clearMiniCache() {
|
||||
Object.keys(require.cache).forEach((k) => {
|
||||
if (k.startsWith(MINI_ROOT + path.sep) || k.startsWith(MINI_ROOT + '/')) delete require.cache[k];
|
||||
});
|
||||
}
|
||||
|
||||
function stubModule(id, exports) {
|
||||
return { id, filename: id, loaded: true, exports, children: [], paths: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 假 MQTT 客户端(隔离无关依赖:只记录"被要求建连"的次数)
|
||||
*/
|
||||
function createFakeMqtt() {
|
||||
const rec = { connectCalls: [], publishCalls: [], disconnectCalls: 0, reconnectCalls: 0 };
|
||||
let held = false; // 是否已持有客户端(canAutoConnect 语义:持有即视为已连/自愈中)
|
||||
const client = {
|
||||
userDisconnected: false,
|
||||
isConnected: () => false,
|
||||
getState: () => 'disconnected',
|
||||
canAutoConnect: () => !client.userDisconnected && !held,
|
||||
onStateChange: () => {},
|
||||
connect: (opts) => {
|
||||
rec.connectCalls.push((opts && opts.clientId) || '');
|
||||
held = true;
|
||||
return Promise.resolve();
|
||||
},
|
||||
reconnect: (opts) => {
|
||||
rec.reconnectCalls++;
|
||||
rec.connectCalls.push((opts && opts.clientId) || '');
|
||||
held = true;
|
||||
return Promise.resolve();
|
||||
},
|
||||
disconnect: () => {
|
||||
rec.disconnectCalls++;
|
||||
client.userDisconnected = true;
|
||||
held = false;
|
||||
return Promise.resolve();
|
||||
},
|
||||
publish: (topic, message) => {
|
||||
rec.publishCalls.push({ topic, message });
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
return {
|
||||
MQTT_STATE: { DISCONNECTED: 'disconnected', CONNECTING: 'connecting', CONNECTED: 'connected' },
|
||||
mqttClient: client,
|
||||
_rec: rec,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备一套干净的运行时并加载被测模块
|
||||
* @param {Object} [opts]
|
||||
* @param {'ok'|'hang'|'fail'} [opts.createMode] - 建链桩行为
|
||||
* @returns {Object} { wx, state, storageArea, gateway, bleService, storage, measurePoint, mqtt, GATEWAY_PHASE, BLE_STATE }
|
||||
*/
|
||||
function loadRuntime(opts = {}) {
|
||||
clearMiniCache();
|
||||
|
||||
const storageArea = createStorageArea();
|
||||
const bleState = createBleState();
|
||||
if (opts.createMode) bleState.createMode = opts.createMode;
|
||||
global.wx = createWx(storageArea, bleState);
|
||||
|
||||
const mqtt = createFakeMqtt();
|
||||
const mqttPath = require.resolve(path.join(MINI_ROOT, 'services/mqtt-client.js'));
|
||||
require.cache[mqttPath] = stubModule(mqttPath, {
|
||||
MQTT_STATE: mqtt.MQTT_STATE,
|
||||
mqttClient: mqtt.mqttClient,
|
||||
});
|
||||
|
||||
const { gateway, GATEWAY_PHASE } = require(path.join(MINI_ROOT, 'services/gateway.js'));
|
||||
const { bleService, BLE_STATE } = require(path.join(MINI_ROOT, 'services/ble.js'));
|
||||
const storage = require(path.join(MINI_ROOT, 'utils/storage.js'));
|
||||
const measurePoint = require(path.join(MINI_ROOT, 'utils/measure-point.js'));
|
||||
|
||||
return {
|
||||
wx: global.wx,
|
||||
state: bleState,
|
||||
storageArea,
|
||||
mqtt,
|
||||
gateway,
|
||||
bleService,
|
||||
storage,
|
||||
measurePoint,
|
||||
GATEWAY_PHASE,
|
||||
BLE_STATE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 造一个已登录 + 已配 MQTT 凭证 + 一台启用设备的常规现场
|
||||
*/
|
||||
function seedScene(rt, { serialNumber = 'BT_ECB0D1002F84', deviceId = 'EC:B0:D1:00:2F:84', enabled = true } = {}) {
|
||||
rt.storage.setUserInfo({ userId: 1, userName: 'tester' });
|
||||
rt.storage.setMqttConfig({ serialNumber, productId: 12, productSecret: 'secret' });
|
||||
rt.measurePoint.setMeasurePointId('371883');
|
||||
rt.storage.addDevice({
|
||||
deviceId,
|
||||
serialNumber,
|
||||
name: serialNumber,
|
||||
bleName: serialNumber,
|
||||
enabled,
|
||||
connected: false,
|
||||
createTime: Date.now(),
|
||||
});
|
||||
if (enabled) rt.storage.activateDevice(serialNumber);
|
||||
return rt.storage.getDevice(serialNumber);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
module.exports = {
|
||||
MINI_ROOT,
|
||||
SVC,
|
||||
CHAR_NOTIFY,
|
||||
CHAR_WRITE,
|
||||
createStorageArea,
|
||||
createBleState,
|
||||
createWx,
|
||||
createFakeMqtt,
|
||||
clearMiniCache,
|
||||
stubModule,
|
||||
loadRuntime,
|
||||
seedScene,
|
||||
sleep,
|
||||
};
|
||||
@ -1,247 +0,0 @@
|
||||
/**
|
||||
* 回归:本轮 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);
|
||||
})();
|
||||
@ -55,25 +55,6 @@
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<!-- 基准点先删掉 -->
|
||||
<!-- 基准点设备:相对测量类设备(静力水准等)需指定基准点,留空表示绝对量测量。不做 deviceType 限制,任意子设备均可配置 -->
|
||||
<el-form-item prop="baselineDeviceId">
|
||||
<template slot="label">
|
||||
<el-tooltip placement="top">
|
||||
<div slot="content">
|
||||
相对测量类设备(如静力水准)需指定基准点。<br/>
|
||||
累计变化量按「(当前读数−基准点读数)−(初始读数−基准点初始读数)」换算。<br/>
|
||||
留空表示绝对量测量,不参与基准点换算。<br/>
|
||||
deviceId 全局唯一,支持跨网关引用(如 GW2 监测点引用 GW1 参考点)。
|
||||
</div>
|
||||
<span>基准点设备 <i class="el-icon-question"></i></span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<el-select v-model="form.baselineDeviceId" placeholder="留空=绝对量测量,不参与基准点换算" clearable
|
||||
filterable size="small" style="width: 100%">
|
||||
<el-option v-for="item in baselineDeviceOptions" :key="item.deviceId"
|
||||
:label="item.deviceName + '(' + item.serialNumber + ')'" :value="Number(item.deviceId)"/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="openServerTip">
|
||||
<template>
|
||||
<el-alert type="info" show-icon :description="$t('device.device-edit.148398-10')"></el-alert>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user