fastbee/yack-mini/miniprogram/pages/device/detail/detail.js
2026-09-27 14:46:05 +08:00

502 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 设备详情页 - 实时数据 + 底部固定收发控制台
*
* 连接与上报全部由应用级网关(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',
});
},
});