502 lines
18 KiB
JavaScript
502 lines
18 KiB
JavaScript
/**
|
||
* 设备详情页 - 实时数据 + 底部固定收发控制台
|
||
*
|
||
* 连接与上报全部由应用级网关(gateway)承担,本页仅:
|
||
* - 上半屏(可滚动):设备信息 / 测点链路配置 / 离线补传
|
||
* - 底部固定收发控制台(串口调试工具风格):
|
||
* · 数据接收:清空 / 滚动(自动跟随) / Hex(hex 或 ASCII 视图),最新在后
|
||
* · 滚动交互:勾选「滚动」时新数据自动滚到底;手动上翻回看自动暂停,
|
||
* 拖回底部或点「↓ 最新」或重新勾选后恢复跟随
|
||
* · 数据发送:勾选 Hex=按 hex 字节发送,取消=按 ASCII 文本发送,
|
||
* 就地经 commandManager 发送(进发送历史、限频),不跳命令页
|
||
*
|
||
* 注意:
|
||
* - 采集启停开关只保留在「我的设备」页(设备卡片),本页只读展示启停状态
|
||
* - 「发送命令」页(pages/device/command)保留未删(预设命令/发送历史可回看),
|
||
* 但本页不再提供入口,发送统一走底部发送框
|
||
*/
|
||
const { gateway } = require('../../../services/gateway.js');
|
||
const { commandManager } = require('../../../services/command-manager.js');
|
||
const storage = require('../../../utils/storage.js');
|
||
const measurePoint = require('../../../utils/measure-point.js');
|
||
const hex = require('../../../utils/hex.js');
|
||
|
||
const app = getApp();
|
||
|
||
Page({
|
||
data: {
|
||
device: null,
|
||
enabled: false, // 只读:来自存储的启停状态(开关在「我的设备」页)
|
||
|
||
// 网关快照(该设备是否为当前采集对象)
|
||
bleConnected: false,
|
||
mqttConnected: false,
|
||
mqttConnecting: false,
|
||
mqttUserDisconnected: false,
|
||
lastError: null,
|
||
// 本设备是网关当前采集对象且 BLE 未连接 → BLE 状态可点击手动重连
|
||
canRetryBle: false,
|
||
retryingBle: false,
|
||
// MQTT 未连接 → 状态可点击手动重连(账号/产品级连接,与本设备是否为采集对象无关)
|
||
canRetryMqtt: false,
|
||
retryingMqtt: false,
|
||
// 离线补传可用性(MQTT 已连且有离线数据)→ 同时驱动 disabled 与禁用态样式
|
||
canFlush: false,
|
||
canSend: false,
|
||
|
||
// 收发控制台(最新在后渲染)
|
||
displayList: [],
|
||
stats: { received: 0, uploaded: 0, failed: 0 },
|
||
offlineCount: 0,
|
||
autoScroll: true, // 「滚动」勾选:新数据自动滚到底
|
||
logFollowing: true, // 运行时跟随态:手动上翻暂停,回到底部恢复
|
||
recvHex: true, // 「Hex」勾选:勾选=hex 视图,取消=ASCII 视图
|
||
scrollIntoView: '',
|
||
|
||
// 发送区
|
||
sendInput: '',
|
||
sendHex: true, // 勾选=按 hex 字节发送,取消=按 ASCII 文本发送
|
||
sending: false,
|
||
|
||
// 测点链路
|
||
measurePointId: '',
|
||
measurePointIdValid: true,
|
||
collectMode: 2, // 1手动确认 2自动上传
|
||
modeManual: false,
|
||
productIdText: '', // 产品ID取自"我的"页 MQTT 配置
|
||
},
|
||
|
||
onLoad(options) {
|
||
const sn = options.sn;
|
||
if (!sn) {
|
||
wx.showToast({ title: '设备信息缺失', icon: 'none' });
|
||
setTimeout(() => wx.navigateBack(), 500);
|
||
return;
|
||
}
|
||
|
||
// 从全局或存储获取设备信息
|
||
let device = app.globalData.currentDevice;
|
||
if (!device || device.serialNumber !== sn) {
|
||
device = storage.getDevice(sn);
|
||
}
|
||
if (!device) {
|
||
wx.showToast({ title: '设备不存在', icon: 'none' });
|
||
setTimeout(() => wx.navigateBack(), 500);
|
||
return;
|
||
}
|
||
|
||
this.sn = sn;
|
||
const storedMpId = measurePoint.getMeasurePointId();
|
||
this.setData({
|
||
device,
|
||
enabled: !!device.enabled,
|
||
measurePointId: storedMpId,
|
||
measurePointIdValid: measurePoint.isValidMeasurePointId(storedMpId) || !storedMpId,
|
||
collectMode: measurePoint.getCollectMode(),
|
||
modeManual: measurePoint.getCollectMode() === measurePoint.COLLECT_MODE.MANUAL,
|
||
});
|
||
},
|
||
|
||
onShow() {
|
||
// 订阅网关,实时渲染
|
||
if (!this._unsub) {
|
||
this._unsub = gateway.subscribe((snap) => this._apply(snap));
|
||
}
|
||
this._apply(gateway.getSnapshot());
|
||
|
||
// 产品ID来自“我的”页 MQTT 配置(设备配置不再录入产品信息)
|
||
const cfg = storage.getMqttConfig();
|
||
this.setData({
|
||
productIdText: (cfg && cfg.productId != null) ? String(cfg.productId) : '',
|
||
});
|
||
|
||
// 启停开关只在「我的设备」页,本页重新显示时同步最新启停态(空状态文案/顶部信息依赖它)
|
||
if (this.sn) {
|
||
const latest = storage.getDevice(this.sn);
|
||
if (latest) {
|
||
this.setData({ device: latest, enabled: !!latest.enabled });
|
||
}
|
||
}
|
||
|
||
// 本设备已启用时,确保网关在采集(幂等)
|
||
if (this.data.device && this.data.device.enabled) {
|
||
gateway.ensureStarted();
|
||
}
|
||
},
|
||
|
||
onHide() {
|
||
this._unsubscribe();
|
||
},
|
||
|
||
onUnload() {
|
||
this._unsubscribe();
|
||
},
|
||
|
||
_unsubscribe() {
|
||
if (this._unsub) {
|
||
this._unsub();
|
||
this._unsub = null;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 应用网关快照到页面
|
||
* 收发区只展示“当前采集设备”的数据;非本设备时不显示其帧与统计
|
||
*/
|
||
_apply(snap) {
|
||
this._lastSnap = snap;
|
||
const dev = this.data.device;
|
||
const isActive = !!(snap.device && dev && snap.device.serialNumber === dev.serialNumber);
|
||
|
||
const list = isActive ? snap.dataList : [];
|
||
// gateway 最新在前;检测头部 id 变化即有新数据
|
||
const hasNew = list.length > 0 && list[0].id !== this._lastHeadId;
|
||
this._lastHeadId = list.length > 0 ? list[0].id : null;
|
||
|
||
const mqttConnected = !!snap.mqttConnected;
|
||
// 连接中(含底层自愈重连中)与"未连接"要分开:前者点击重连没有意义,
|
||
// 后者才是需要一键恢复的异常态
|
||
const mqttConnecting = !mqttConnected && snap.mqttState === 'connecting';
|
||
this.setData({
|
||
bleConnected: isActive && snap.bleConnected,
|
||
// MQTT 是账号/产品级连接(凭证在「我的」页),与"本设备是否为采集对象"无关:
|
||
// 早期把它与 isActive 绑定,导致非采集设备详情页恒显示"未连接",重连入口也随之失效
|
||
mqttConnected,
|
||
mqttConnecting,
|
||
mqttUserDisconnected: !!snap.mqttUserDisconnected,
|
||
// 只要未连接就允许点重连:底层自愈重连中(connecting)也可能卡在错误凭证上,
|
||
// 此时"点了就硬重连"正是用户想要的,不能用 connecting 把它挡住
|
||
canRetryMqtt: !mqttConnected && !this.data.retryingMqtt,
|
||
// 补传是设备级动作(_flushOffline 只处理当前采集设备的缓存项),仍按 isActive 约束
|
||
canFlush: isActive && mqttConnected && snap.offlineCount > 0,
|
||
canSend: isActive && !!snap.bleConnected && !this.data.sending,
|
||
lastError: isActive ? snap.lastError : null,
|
||
canRetryBle: isActive && !snap.bleConnected && !this.data.retryingBle,
|
||
displayList: this._renderList(list),
|
||
stats: isActive ? snap.stats : { received: 0, uploaded: 0, failed: 0 },
|
||
offlineCount: snap.offlineCount,
|
||
});
|
||
|
||
// 有新数据且处于跟随态 → 锚定最后一行(最新在后,滚到底)
|
||
if (hasNew && this.data.autoScroll && this.data.logFollowing) {
|
||
this._scrollToLatest();
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 渲染列表:gateway 最新在前 → 控制台最新在后(串口日志惯例)
|
||
* 并按 recvHex 决定显示 hex 或 ASCII 文本
|
||
*/
|
||
_renderList(list) {
|
||
const recvHex = this.data.recvHex;
|
||
return list.slice().reverse().map((it) => ({
|
||
...it,
|
||
text: recvHex ? it.hex : hex.asciiOfHex(it.hex),
|
||
}));
|
||
},
|
||
|
||
/**
|
||
* 自动滚到底部(scroll-into-view 锚定最后一行)
|
||
* @param {boolean} force - 值未变化时也强制重新定位(恢复跟随/重新勾选场景)
|
||
*/
|
||
_scrollToLatest(force) {
|
||
const list = this.data.displayList;
|
||
if (!list.length) return;
|
||
const target = 'log-' + list[list.length - 1].id;
|
||
if (this.data.scrollIntoView === target) {
|
||
if (!force) return;
|
||
// 值相同时 scroll-into-view 不会重新定位:先清空,下一帧再设
|
||
this.setData({ scrollIntoView: '' });
|
||
setTimeout(() => this.setData({ scrollIntoView: target }), 0);
|
||
return;
|
||
}
|
||
this.setData({ scrollIntoView: target });
|
||
},
|
||
|
||
/**
|
||
* 手动重连 BLE(点击 BLE 状态触发)
|
||
* 走网关的适配器复位重连:连续连接超时后 native 会停在半开连接,
|
||
* 自动重连与"关开关重开"都刷不掉,必须复位适配器
|
||
*/
|
||
async onRetryBle() {
|
||
if (!this.data.canRetryBle || this.data.retryingBle) return;
|
||
|
||
this.setData({ retryingBle: true, canRetryBle: false });
|
||
wx.showLoading({ title: '重连中...', mask: true });
|
||
|
||
let res;
|
||
try {
|
||
res = await gateway.retryBle();
|
||
} catch (err) {
|
||
res = { ok: false, message: (err && err.message) || '重连异常' };
|
||
} finally {
|
||
// loading 与在途闸门必须无条件回收:retryingBle 残留会让状态标签永久点不动
|
||
wx.hideLoading();
|
||
this.setData({ retryingBle: false });
|
||
this._apply(gateway.getSnapshot());
|
||
}
|
||
|
||
wx.showToast({
|
||
title: res.ok ? 'BLE 已重连' : (res.message || '重连失败'),
|
||
icon: res.ok ? 'success' : 'none',
|
||
duration: 2000,
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 手动重连 MQTT(点击 MQTT 状态触发)
|
||
*
|
||
* 与 BLE 同理:底层 lib 的自动重连可能停在僵尸 socket 上(close 之后 client 未清空),
|
||
* 这里走"关闭旧客户端 → 用「我的」页已保存的凭证重建"的确定性出口,成功后自动补传离线
|
||
*/
|
||
async onRetryMqtt() {
|
||
if (!this.data.canRetryMqtt || this.data.retryingMqtt) return;
|
||
|
||
this.setData({ retryingMqtt: true, canRetryMqtt: false });
|
||
wx.showLoading({ title: '重连中...', mask: true });
|
||
|
||
let res;
|
||
try {
|
||
res = await gateway.retryMqtt();
|
||
} catch (err) {
|
||
res = { ok: false, message: (err && err.message) || '重连异常' };
|
||
} finally {
|
||
// 同 BLE:loading 与在途闸门必须无条件回收
|
||
wx.hideLoading();
|
||
this.setData({ retryingMqtt: false });
|
||
this._apply(gateway.getSnapshot());
|
||
}
|
||
|
||
wx.showToast({
|
||
title: res.ok ? 'MQTT 已重连' : (res.message || '重连失败'),
|
||
icon: res.ok ? 'success' : 'none',
|
||
duration: 2000,
|
||
});
|
||
},
|
||
|
||
/* ===================== 接收区交互 ===================== */
|
||
|
||
/**
|
||
* 「滚动」开关:勾选立即回到底并恢复跟随;取消则完全停止自动滚动
|
||
* 值取自 checkbox-group 的 detail.value(选中项 value 数组),**不是**布尔值
|
||
*/
|
||
onAutoScrollToggle(e) {
|
||
const checked = e.detail.value.includes('scroll');
|
||
if (checked) {
|
||
this.setData({ autoScroll: true, logFollowing: true });
|
||
this._scrollToLatest(true);
|
||
} else {
|
||
this.setData({ autoScroll: false, logFollowing: false });
|
||
}
|
||
},
|
||
|
||
/** 「Hex」开关:hex 视图 ↔ ASCII 视图,用最近一次快照重渲染 */
|
||
onRecvHexToggle(e) {
|
||
const recvHex = e.detail.value.includes('hex');
|
||
this.setData({ recvHex });
|
||
const snap = this._lastSnap;
|
||
if (snap) {
|
||
const dev = this.data.device;
|
||
const isActive = !!(snap.device && dev && snap.device.serialNumber === dev.serialNumber);
|
||
this.setData({ displayList: this._renderList(isActive ? snap.dataList : []) });
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 日志滚动:最新在后,向上滚(deltaY<0)=回看旧数据 → 暂停自动跟随;
|
||
* 程序自动滚底为向下滚(deltaY>0),不会误触发暂停
|
||
*/
|
||
onLogScroll(e) {
|
||
if (e.detail.deltaY < 0 && this.data.logFollowing) {
|
||
this.setData({ logFollowing: false });
|
||
}
|
||
},
|
||
|
||
/** 拖回底部(lower-threshold 内)→ 恢复跟随 */
|
||
onLogToLower() {
|
||
if (!this.data.logFollowing) {
|
||
this.setData({ logFollowing: true });
|
||
}
|
||
},
|
||
|
||
/** 点「↓ 最新」浮标 → 回到底部并恢复跟随 */
|
||
onFollowTap() {
|
||
this.setData({ logFollowing: true });
|
||
this._scrollToLatest(true);
|
||
},
|
||
|
||
/** 清空接收日志与统计(控制台一键清空,不影响离线缓存) */
|
||
handleClearData() {
|
||
gateway.clearHistory();
|
||
this._lastHeadId = null;
|
||
},
|
||
|
||
/* ===================== 发送区 ===================== */
|
||
|
||
onSendInput(e) {
|
||
const value = e.detail.value;
|
||
// hex 模式统一大写便于阅读;ASCII 模式保留原文
|
||
this.setData({ sendInput: this.data.sendHex ? value.toUpperCase() : value });
|
||
},
|
||
|
||
/**
|
||
* 发送「Hex」开关:切换时把已有输入统一为大写(hex 语义)
|
||
* 同接收区:值取自 checkbox-group 的 value 数组
|
||
*/
|
||
onSendHexToggle(e) {
|
||
const sendHex = e.detail.value.includes('hex');
|
||
this.setData({
|
||
sendHex,
|
||
sendInput: sendHex ? this.data.sendInput.toUpperCase() : this.data.sendInput,
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 发送命令(就地发送,不跳命令页)
|
||
* hex 模式:交由 commandManager 校验并写 BLE;ASCII 模式:逐字符转字节后发送
|
||
* 两条路都进发送历史(命令页「发送历史」tab 可回看)
|
||
*/
|
||
async handleSend() {
|
||
const { sendInput, sendHex, bleConnected, device, sending } = this.data;
|
||
if (sending) return;
|
||
if (!sendInput || !sendInput.trim()) {
|
||
wx.showToast({ title: '请输入发送内容', icon: 'none' });
|
||
return;
|
||
}
|
||
if (!bleConnected) {
|
||
wx.showToast({ title: 'BLE 未连接', icon: 'none' });
|
||
return;
|
||
}
|
||
|
||
let payload;
|
||
let name;
|
||
if (sendHex) {
|
||
payload = sendInput.replace(/\s+/g, '');
|
||
name = '手动发送';
|
||
} else {
|
||
// ASCII 文本逐字符编码为字节(空格也是内容,不去除)
|
||
payload = hex.asciiTextToHex(sendInput);
|
||
if (payload == null) {
|
||
wx.showToast({ title: 'ASCII 模式不支持中文等非拉丁字符', icon: 'none' });
|
||
return;
|
||
}
|
||
name = '手动发送(ASCII)';
|
||
}
|
||
|
||
this.setData({ sending: true, canSend: false });
|
||
wx.showLoading({ title: '发送中...', mask: true });
|
||
try {
|
||
await commandManager.sendCommand(payload, 'BLE', device, { name });
|
||
wx.hideLoading();
|
||
wx.showToast({ title: '已发送', icon: 'success', duration: 1000 });
|
||
} catch (err) {
|
||
wx.hideLoading();
|
||
wx.showToast({ title: err.message || '发送失败', icon: 'none' });
|
||
} finally {
|
||
this.setData({ sending: false, canSend: this.data.bleConnected });
|
||
}
|
||
},
|
||
|
||
/* ===================== 测点链路 ===================== */
|
||
|
||
/**
|
||
* 测点ID输入(数据唯一归属,上报 topic 携带)
|
||
* 实时校验格式:仅纯数字,1-64 位
|
||
* 非法时不写入存储(旧测点ID继续生效),并节流提示"未生效",防止界面值与上报归属不一致的误导
|
||
*/
|
||
onMeasurePointInput(e) {
|
||
const value = e.detail.value;
|
||
const isValid = measurePoint.isValidMeasurePointId(value) || !value;
|
||
if (isValid) {
|
||
measurePoint.setMeasurePointId(value);
|
||
} else if (!this._invalidTipAt || Date.now() - this._invalidTipAt > 1500) {
|
||
this._invalidTipAt = Date.now();
|
||
wx.showToast({ title: '仅支持纯数字,本次输入未生效', icon: 'none', duration: 1500 });
|
||
}
|
||
this.setData({
|
||
measurePointId: value,
|
||
measurePointIdValid: isValid,
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 采集模式切换:1手动确认 / 2自动上传(纯前端行为)
|
||
* 从手动切自动时,若有未确认数据给出提示(不强制拦截,切回自动后未确认数据不再上报)
|
||
*/
|
||
onModeChange(e) {
|
||
const mode = parseInt(e.detail.value);
|
||
const prevMode = this.data.collectMode;
|
||
if (prevMode === measurePoint.COLLECT_MODE.MANUAL && mode === measurePoint.COLLECT_MODE.AUTO) {
|
||
const unconfirmed = (this.data.displayList || []).filter(it => !it.uploaded && !it.confirmed);
|
||
if (unconfirmed.length > 0) {
|
||
wx.showModal({
|
||
title: '模式切换',
|
||
content: `当前有 ${unconfirmed.length} 条未确认数据,切换到自动模式后将不再上报这些数据。确定继续?`,
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
measurePoint.setCollectMode(mode);
|
||
this.setData({
|
||
collectMode: mode,
|
||
modeManual: mode === measurePoint.COLLECT_MODE.MANUAL,
|
||
});
|
||
} else {
|
||
// 小程序 radio change 后内部选中态已切换,取消需强制重渲染拉回受控状态
|
||
this.setData({ collectMode: 0 });
|
||
setTimeout(() => {
|
||
this.setData({
|
||
collectMode: prevMode,
|
||
modeManual: prevMode === measurePoint.COLLECT_MODE.MANUAL,
|
||
});
|
||
}, 0);
|
||
}
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
measurePoint.setCollectMode(mode);
|
||
this.setData({
|
||
collectMode: mode,
|
||
modeManual: mode === measurePoint.COLLECT_MODE.MANUAL,
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 手动确认上报单条数据(模式1)
|
||
*/
|
||
async onConfirmData(e) {
|
||
const itemId = e.currentTarget.dataset.id;
|
||
try {
|
||
await gateway.confirmData(itemId);
|
||
wx.showToast({ title: '已确认上报', icon: 'success' });
|
||
} catch (err) {
|
||
wx.showToast({ title: err.message || '确认失败', icon: 'none' });
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 手动补传离线缓存
|
||
*/
|
||
async handleFlushOffline() {
|
||
const { offlineCount } = this.data;
|
||
if (!this.data.mqttConnected) {
|
||
wx.showToast({ title: 'MQTT 未连接', icon: 'none' });
|
||
return;
|
||
}
|
||
if (offlineCount === 0) {
|
||
wx.showToast({ title: '暂无离线数据', icon: 'none' });
|
||
return;
|
||
}
|
||
|
||
wx.showLoading({ title: '上传中...', mask: true });
|
||
const before = gateway.getSnapshot().offlineCount;
|
||
await gateway.flushOffline();
|
||
wx.hideLoading();
|
||
const after = gateway.getSnapshot().offlineCount;
|
||
wx.showToast({
|
||
title: `已上传 ${before - after} 条`,
|
||
icon: 'none',
|
||
});
|
||
},
|
||
});
|