feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* MqttClient —— 浏览器/小程序 MQTT over WebSocket 重连封装
|
||||
*
|
||||
* 稳定性策略(对应路线 B2「即时通信稳定性」):
|
||||
* 1. 指数退避重连:基数 1s,上限 30s,成功一次后重置。
|
||||
* 2. 会话恢复:clean=false 时重连自动补投离线消息(由 Broker 保证)。
|
||||
* 3. 遗嘱(LWT):连接异常断开时 Broker 代发 will,上层可据此标记「离线」。
|
||||
* 4. 心跳兜底:onclose 即触发重连,不依赖 broker 的 keepalive 超时。
|
||||
*
|
||||
* 用法:
|
||||
* const c = new MqttClient('wss://your.host/mqtt', {
|
||||
* clientId: 'web-' + uid, username, password, clean: false,
|
||||
* will: { topic: 'presence/' + uid, payload: 'offline', qos: 1, retain: true }
|
||||
* });
|
||||
* c.on('connect', () => c.subscribe('topic/a'));
|
||||
* c.on('message', (t, p) => console.log(t, p));
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
const BACKOFF_BASE = 1000; // 1s
|
||||
const BACKOFF_MAX = 30000; // 30s
|
||||
const BACKOFF_FACTOR = 1.8;
|
||||
|
||||
function MqttClient(url, opts) {
|
||||
this.url = url;
|
||||
this.opts = opts || {};
|
||||
this.clientId = this.opts.clientId || ('mqttjs_' + Math.random().toString(16).slice(2, 10));
|
||||
this._listeners = {};
|
||||
this._ws = null;
|
||||
this._connected = false;
|
||||
this._closedByUser = false;
|
||||
this._backoff = BACKOFF_BASE;
|
||||
this._retryTimer = null;
|
||||
this._subscriptions = {}; // topic -> qos,重连后自动重订
|
||||
}
|
||||
|
||||
MqttClient.prototype.on = function (evt, fn) {
|
||||
(this._listeners[evt] = this._listeners[evt] || []).push(fn);
|
||||
return this;
|
||||
};
|
||||
|
||||
MqttClient.prototype._emit = function (evt, a, b) {
|
||||
(this._listeners[evt] || []).forEach((fn) => fn(a, b));
|
||||
};
|
||||
|
||||
MqttClient.prototype.connect = function () {
|
||||
this._closedByUser = false;
|
||||
this._open();
|
||||
return this;
|
||||
};
|
||||
|
||||
MqttClient.prototype._open = function () {
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(this.url, ['mqtt']);
|
||||
} catch (e) {
|
||||
return this._scheduleReconnect();
|
||||
}
|
||||
this._ws = ws;
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
// 发送 MQTT CONNECT(极简 CONNECT 报文,支持 will)
|
||||
const buf = this._buildConnect();
|
||||
ws.send(buf);
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
const pkt = this._parseConnAck(ev.data);
|
||||
if (pkt && pkt.sessionPresent) {
|
||||
this._emit('resume'); // 会话恢复,Broker 会在后台补投离线消息
|
||||
}
|
||||
this._connected = true;
|
||||
this._backoff = BACKOFF_BASE;
|
||||
this._emit('connect');
|
||||
Object.keys(this._subscriptions).forEach((t) => this.subscribe(t, this._subscriptions[t]));
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (this._connected) this._emit('disconnect');
|
||||
this._connected = false;
|
||||
if (!this._closedByUser) this._scheduleReconnect();
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
||||
};
|
||||
|
||||
MqttClient.prototype._scheduleReconnect = function () {
|
||||
if (this._closedByUser) return;
|
||||
this._emit('reconnecting', this._backoff);
|
||||
clearTimeout(this._retryTimer);
|
||||
this._retryTimer = setTimeout(() => {
|
||||
this._backoff = Math.min(BACKOFF_MAX, Math.floor(this._backoff * BACKOFF_FACTOR));
|
||||
this._open();
|
||||
}, this._backoff);
|
||||
};
|
||||
|
||||
MqttClient.prototype.subscribe = function (topic, qos) {
|
||||
this._subscriptions[topic] = qos || 0;
|
||||
if (!this._connected || !this._ws) return;
|
||||
this._ws.send(this._buildSubscribe(topic, qos || 0));
|
||||
};
|
||||
|
||||
MqttClient.prototype.publish = function (topic, payload, qos, retain) {
|
||||
if (!this._connected || !this._ws) return false;
|
||||
this._ws.send(this._buildPublish(topic, payload, qos || 0, retain ? 1 : 0));
|
||||
return true;
|
||||
};
|
||||
|
||||
MqttClient.prototype.end = function () {
|
||||
this._closedByUser = true;
|
||||
clearTimeout(this._retryTimer);
|
||||
if (this._ws) { try { this._ws.close(); } catch (e) {} }
|
||||
};
|
||||
|
||||
/* ---------- 以下为最小 MQTT 报文构造(仅覆盖 CONNECT/SUBSCRIBE/PUBLISH) ---------- */
|
||||
MqttClient.prototype._encStr = function (s) {
|
||||
const b = new TextEncoder().encode(s);
|
||||
return new Uint8Array([b.length >> 8, b.length & 0xff, ...b]);
|
||||
};
|
||||
MqttClient.prototype._buildConnect = function () {
|
||||
const o = this.opts;
|
||||
const cid = this._encStr(this.clientId);
|
||||
let payload = cid;
|
||||
if (o.username) payload = this._join(payload, this._encStr(o.username), this._encStr(o.password || ''));
|
||||
if (o.will) {
|
||||
payload = this._join(payload, this._encStr(o.will.topic), this._encStr(o.will.payload || ''));
|
||||
}
|
||||
let varHdr = new Uint8Array([4, 0x02 /*clean*/, 0x3c /*keepalive 60*/]);
|
||||
if (o.clean === false) varHdr[1] = 0;
|
||||
let flags = 0;
|
||||
if (o.username) flags |= 0x80;
|
||||
if (o.password) flags |= 0x40;
|
||||
if (o.will) flags |= 0x04 | ((o.will.qos || 0) << 3) | ((o.will.retain ? 1 : 0) << 5);
|
||||
varHdr[1] |= flags;
|
||||
const body = this._join(varHdr, payload);
|
||||
return this._wrap(1, body);
|
||||
};
|
||||
MqttClient.prototype._buildSubscribe = function (topic, qos) {
|
||||
const pid = new Uint8Array([0x00, 0x01]);
|
||||
const t = this._join(this._encStr(topic), new Uint8Array([qos]));
|
||||
return this._wrap(8, this._join(pid, t));
|
||||
};
|
||||
MqttClient.prototype._buildPublish = function (topic, payload, qos, retain) {
|
||||
const t = this._encStr(topic);
|
||||
const p = typeof payload === 'string' ? new TextEncoder().encode(payload) : new Uint8Array(payload);
|
||||
return this._wrap(3 | (qos << 1) | (retain << 0), this._join(t, p));
|
||||
};
|
||||
MqttClient.prototype._wrap = function (cmd, body) {
|
||||
const len = body.length;
|
||||
const rem = [len & 0x7f];
|
||||
if (len > 127) rem.unshift((len >> 7) & 0x7f | 0x80);
|
||||
return new Uint8Array([cmd << 4, ...rem, ...body]);
|
||||
};
|
||||
MqttClient.prototype._join = function (...arrs) {
|
||||
let n = 0; arrs.forEach((a) => n += a.length);
|
||||
const out = new Uint8Array(n);
|
||||
let p = 0; arrs.forEach((a) => { out.set(a, p); p += a.length; });
|
||||
return out;
|
||||
};
|
||||
MqttClient.prototype._parseConnAck = function (data) {
|
||||
try {
|
||||
const buf = new Uint8Array(data);
|
||||
return { sessionPresent: (buf[2] & 0x01) === 1, code: buf[3] };
|
||||
} catch (e) { return null; }
|
||||
};
|
||||
|
||||
global.MqttClient = MqttClient;
|
||||
if (typeof module !== 'undefined') module.exports = MqttClient;
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
@@ -0,0 +1,585 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 服务器与登录 -->
|
||||
<view class="card">
|
||||
<view class="card-title">添加设备</view>
|
||||
<view class="row">
|
||||
<text class="label">API地址</text>
|
||||
<input class="input" v-model="apiBase" placeholder="http://192.168.1.10" @blur="saveStorage" />
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">账号</text>
|
||||
<input class="input" v-model="loginAccount" placeholder="会员账号" />
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">密码</text>
|
||||
<input class="input" v-model="loginPassword" placeholder="会员密码" password="true" />
|
||||
</view>
|
||||
<button class="btn btn-primary" @click="onLogin">{{ accessToken.length > 0 ? '重新登录' : '登录' }}</button>
|
||||
<view class="status" :class="loginStatusClass">{{ loginStatusText }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 绑定表单 -->
|
||||
<view class="card">
|
||||
<view class="card-title">绑定设备</view>
|
||||
<view class="row">
|
||||
<input class="input flex1" v-model="devSn" placeholder="设备识别码:MAC(如a0b1c2d3e4f5)或SN" />
|
||||
<button class="btn btn-sm" @click="onScan">扫码</button>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">配对码</text>
|
||||
<input class="input" v-model="pairCode" placeholder="选填(设备设了配对码才填)" />
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">名称</text>
|
||||
<input class="input" v-model="title" placeholder="选填,默认设备识别码" />
|
||||
</view>
|
||||
<button class="btn btn-primary" :class="binding ? 'btn-disabled' : ''" @click="onBind">
|
||||
{{ binding ? '绑定中...' : '立即绑定' }}
|
||||
</button>
|
||||
<view class="status" :class="bindStatusClass">{{ bindStatusText }}</view>
|
||||
<view class="tips" v-if="bindResult.length > 0">
|
||||
<text class="tips-title">绑定成功,设备信息:</text>
|
||||
<text class="tips-mono">{{ bindResult }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的设备 -->
|
||||
<view class="card">
|
||||
<view class="card-title">
|
||||
<text>我的设备({{ devices.length }})</text>
|
||||
<text class="link" @click="onLoadDevices">刷新</text>
|
||||
</view>
|
||||
<view class="empty" v-if="devices.length === 0 && !loadingDevices">暂无设备,绑定后自动出现在这里</view>
|
||||
<view class="dev-item" v-for="(d, idx) in devices" :key="idx">
|
||||
<view class="dev-head">
|
||||
<text class="dev-name">{{ d.title.length > 0 ? d.title : d.sn }}</text>
|
||||
<text class="badge" :class="d.online ? 'badge-on' : 'badge-off'">{{ d.online ? '在线' : '离线' }}</text>
|
||||
<text class="badge badge-role">{{ d.role === 'owner' ? '我的' : '共享' }}</text>
|
||||
</view>
|
||||
<view class="dev-meta">{{ d.dev_type }}<text v-if="d.model.length > 0"> · {{ d.model }}</text><text v-if="d.ver.length > 0"> · v{{ d.ver }}</text></view>
|
||||
<view class="dev-sn">SN: {{ d.sn }}<text v-if="d.mac.length > 0"> · MAC: {{ d.mac }}</text></view>
|
||||
<view class="dev-ops">
|
||||
<button class="btn btn-sm btn-control" @click="onOpenPanel(d)">控制</button>
|
||||
<button class="btn btn-sm btn-danger" @click="onUnbind(d)">解绑</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tips page-tips">
|
||||
MAC 设备上电自动注册入网,绑定无需配对码;预登记设备按机身 SN + 配对码绑定。
|
||||
绑定后即可用 myDevices 返回的 topics 订阅设备状态与控制。
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
|
||||
interface DeviceInfo {
|
||||
id: number
|
||||
sn: string
|
||||
mac: string
|
||||
uuid: string
|
||||
dev_type: string
|
||||
ver: string
|
||||
title: string
|
||||
model: string
|
||||
room: string
|
||||
online: boolean
|
||||
role: string
|
||||
}
|
||||
|
||||
// ─── 响应式状态 ───────────────────────────────────
|
||||
const apiBase = ref('http://192.168.1.10')
|
||||
const loginAccount = ref('')
|
||||
const loginPassword = ref('')
|
||||
const accessToken = ref('')
|
||||
const loginStatusText = ref('未登录')
|
||||
const loginStatusLevel = ref('idle')
|
||||
const devSn = ref('')
|
||||
const pairCode = ref('')
|
||||
const title = ref('')
|
||||
const binding = ref(false)
|
||||
const bindStatusText = ref('')
|
||||
const bindStatusLevel = ref('idle')
|
||||
const bindResult = ref('')
|
||||
const devices = ref<DeviceInfo[]>([])
|
||||
const loadingDevices = ref(false)
|
||||
|
||||
// ─── 计算属性 ───────────────────────────────────
|
||||
const loginStatusClass = computed<string>((): string => {
|
||||
if (loginStatusLevel.value === 'ok') {
|
||||
return 'status-ok'
|
||||
} else if (loginStatusLevel.value === 'err') {
|
||||
return 'status-err'
|
||||
} else if (loginStatusLevel.value === 'busy') {
|
||||
return 'status-busy'
|
||||
}
|
||||
return 'status-idle'
|
||||
})
|
||||
|
||||
const bindStatusClass = computed<string>((): string => {
|
||||
if (bindStatusLevel.value === 'ok') {
|
||||
return 'status-ok'
|
||||
} else if (bindStatusLevel.value === 'err') {
|
||||
return 'status-err'
|
||||
} else if (bindStatusLevel.value === 'busy') {
|
||||
return 'status-busy'
|
||||
}
|
||||
return 'status-idle'
|
||||
})
|
||||
|
||||
// ─── 生命周期 ───────────────────────────────────
|
||||
onLoad(() => {
|
||||
const savedBase = uni.getStorageSync('mqtt_apiBase') as string | null
|
||||
if (savedBase != null && (savedBase as string).length > 0) {
|
||||
apiBase.value = savedBase as string
|
||||
}
|
||||
const savedToken = uni.getStorageSync('mqtt_token') as string | null
|
||||
if (savedToken != null && (savedToken as string).length > 0) {
|
||||
accessToken.value = savedToken as string
|
||||
loginStatusText.value = '已登录(凭证已保存)'
|
||||
loginStatusLevel.value = 'ok'
|
||||
}
|
||||
})
|
||||
|
||||
// ─── 请求 ───────────────────────────────────
|
||||
function saveStorage() {
|
||||
uni.setStorageSync('mqtt_apiBase', apiBase.value)
|
||||
}
|
||||
|
||||
function authHeader(): UTSJSONObject {
|
||||
const header: UTSJSONObject = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
if (accessToken.value.length > 0) {
|
||||
header['Authorization'] = 'Bearer ' + accessToken.value
|
||||
}
|
||||
return header
|
||||
}
|
||||
|
||||
function httpPost(path: string, body: UTSJSONObject, needToken: boolean, onOk: (resp: UTSJSONObject) => void, onFail: (msg: string) => void) {
|
||||
uni.request<UTSJSONObject>({
|
||||
url: apiBase.value + path,
|
||||
method: 'POST',
|
||||
data: body,
|
||||
header: authHeader(),
|
||||
success: (res: RequestSuccess<UTSJSONObject>) => {
|
||||
const resp = res.data
|
||||
const code = resp['code'] as number
|
||||
// 兼容插件 Result::success 的 message 键 与 框架 apiSuccess 的 msg 键
|
||||
const msg = (resp['message'] as string) || (resp['msg'] as string) || ''
|
||||
if (code === 0) {
|
||||
onOk(resp)
|
||||
} else {
|
||||
onFail(msg.length > 0 ? msg : '请求失败 code=' + code)
|
||||
}
|
||||
},
|
||||
fail: (err: RequestFail) => {
|
||||
onFail(err.errMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function httpGet(path: string, needToken: boolean, onOk: (resp: UTSJSONObject) => void, onFail: (msg: string) => void) {
|
||||
uni.request<UTSJSONObject>({
|
||||
url: apiBase.value + path,
|
||||
method: 'GET',
|
||||
header: authHeader(),
|
||||
success: (res: RequestSuccess<UTSJSONObject>) => {
|
||||
const resp = res.data
|
||||
const code = resp['code'] as number
|
||||
const msg = (resp['message'] as string) || (resp['msg'] as string) || ''
|
||||
if (code === 0) {
|
||||
onOk(resp)
|
||||
} else {
|
||||
onFail(msg.length > 0 ? msg : '请求失败 code=' + code)
|
||||
}
|
||||
},
|
||||
fail: (err: RequestFail) => {
|
||||
onFail(err.errMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 动作 ───────────────────────────────────
|
||||
function onLogin() {
|
||||
if (loginAccount.value.length === 0 || loginPassword.value.length === 0) {
|
||||
loginStatusText.value = '请输入账号与密码'
|
||||
loginStatusLevel.value = 'err'
|
||||
return
|
||||
}
|
||||
loginStatusText.value = '登录中...'
|
||||
loginStatusLevel.value = 'busy'
|
||||
const body: UTSJSONObject = {
|
||||
'account': loginAccount.value,
|
||||
'password': loginPassword.value
|
||||
}
|
||||
httpPost('/api/v1/user/login', body, false, (resp: UTSJSONObject) => {
|
||||
accessToken.value = (resp['access_token'] as string) || ''
|
||||
if (accessToken.value.length === 0) {
|
||||
loginStatusText.value = '登录成功但未返回 token'
|
||||
loginStatusLevel.value = 'err'
|
||||
return
|
||||
}
|
||||
uni.setStorageSync('mqtt_token', accessToken.value)
|
||||
loginStatusText.value = '登录成功,凭证已保存'
|
||||
loginStatusLevel.value = 'ok'
|
||||
onLoadDevices()
|
||||
}, (msg: string) => {
|
||||
loginStatusText.value = msg
|
||||
loginStatusLevel.value = 'err'
|
||||
})
|
||||
}
|
||||
|
||||
function onScan() {
|
||||
uni.scanCode({
|
||||
success: (res: ScanCodeSuccess) => {
|
||||
if (res.result != null) {
|
||||
devSn.value = res.result
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
bindStatusText.value = '扫码取消'
|
||||
bindStatusLevel.value = 'err'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onBind() {
|
||||
if (accessToken.value.length === 0) {
|
||||
bindStatusText.value = '请先登录'
|
||||
bindStatusLevel.value = 'err'
|
||||
return
|
||||
}
|
||||
const sn = devSn.value.trim()
|
||||
if (sn.length === 0) {
|
||||
bindStatusText.value = '请输入设备识别码(MAC 或 SN)'
|
||||
bindStatusLevel.value = 'err'
|
||||
return
|
||||
}
|
||||
binding.value = true
|
||||
bindStatusText.value = '绑定中...'
|
||||
bindStatusLevel.value = 'busy'
|
||||
const body: UTSJSONObject = {
|
||||
'sn': sn
|
||||
}
|
||||
if (pairCode.value.trim().length > 0) {
|
||||
body['pair_code'] = pairCode.value.trim()
|
||||
}
|
||||
if (title.value.trim().length > 0) {
|
||||
body['title'] = title.value.trim()
|
||||
}
|
||||
httpPost('/iot/api/client/bindDevice', body, true, (resp: UTSJSONObject) => {
|
||||
binding.value = false
|
||||
const data = resp['data'] as UTSJSONObject | null
|
||||
if (data != null) {
|
||||
bindResult.value = JSON.stringify(data)
|
||||
const devType = (data['dev_type'] as string) || ''
|
||||
const ver = (data['ver'] as string) || ''
|
||||
bindStatusText.value = '绑定成功' + (devType.length > 0 ? '(' + devType + (ver.length > 0 ? ' v' + ver : '') + ')' : '')
|
||||
bindStatusLevel.value = 'ok'
|
||||
} else {
|
||||
bindStatusText.value = '绑定成功'
|
||||
bindStatusLevel.value = 'ok'
|
||||
}
|
||||
devSn.value = ''
|
||||
pairCode.value = ''
|
||||
title.value = ''
|
||||
onLoadDevices()
|
||||
}, (msg: string) => {
|
||||
binding.value = false
|
||||
bindStatusText.value = msg
|
||||
bindStatusLevel.value = 'err'
|
||||
})
|
||||
}
|
||||
|
||||
function onLoadDevices() {
|
||||
if (accessToken.value.length === 0) {
|
||||
return
|
||||
}
|
||||
loadingDevices.value = true
|
||||
httpGet('/iot/api/client/myDevices', true, (resp: UTSJSONObject) => {
|
||||
loadingDevices.value = false
|
||||
const data = resp['data'] as UTSJSONObject | null
|
||||
const list = (data != null ? (data['list'] as Array<UTSJSONObject>) : null) || []
|
||||
const arr: DeviceInfo[] = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const it = list[i]
|
||||
arr.push({
|
||||
id: (it['id'] as number) || 0,
|
||||
sn: (it['sn'] as string) || '',
|
||||
mac: (it['mac'] as string) || '',
|
||||
uuid: (it['uuid'] as string) || '',
|
||||
dev_type: (it['dev_type'] as string) || '',
|
||||
ver: (it['ver'] as string) || '',
|
||||
title: (it['title'] as string) || '',
|
||||
model: (it['model'] as string) || '',
|
||||
room: (it['room'] as string) || '',
|
||||
online: (it['online'] as boolean) || false,
|
||||
role: (it['role'] as string) || 'owner'
|
||||
})
|
||||
}
|
||||
devices.value = arr
|
||||
}, (msg: string) => {
|
||||
loadingDevices.value = false
|
||||
bindStatusText.value = '拉取设备失败:' + msg
|
||||
bindStatusLevel.value = 'err'
|
||||
})
|
||||
}
|
||||
|
||||
function onOpenPanel(d: DeviceInfo) {
|
||||
uni.navigateTo({
|
||||
url: '../device-panel/index?uuid=' + d.uuid +
|
||||
'&title=' + encodeURIComponent(d.title) +
|
||||
'&dev_type=' + d.dev_type +
|
||||
'&model=' + encodeURIComponent(d.model) +
|
||||
'&ver=' + d.ver +
|
||||
'&sn=' + encodeURIComponent(d.sn) +
|
||||
'&online=' + (d.online ? '1' : '0')
|
||||
})
|
||||
}
|
||||
|
||||
function onUnbind(d: DeviceInfo) {
|
||||
uni.showModal({
|
||||
title: '解绑设备',
|
||||
content: '确定解绑 ' + (d.title.length > 0 ? d.title : d.sn) + ' 吗?',
|
||||
success: (res: ShowModalSuccess) => {
|
||||
if (res.confirm === true) {
|
||||
doUnbind(d)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function doUnbind(d: DeviceInfo) {
|
||||
const body: UTSJSONObject = {
|
||||
'uuid': d.uuid
|
||||
}
|
||||
httpPost('/iot/api/client/unbindDevice', body, true, (resp: UTSJSONObject) => {
|
||||
bindStatusText.value = '已解绑 ' + d.sn
|
||||
bindStatusLevel.value = 'ok'
|
||||
onLoadDevices()
|
||||
}, (msg: string) => {
|
||||
bindStatusText.value = '解绑失败:' + msg
|
||||
bindStatusLevel.value = 'err'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 24rpx;
|
||||
background-color: #f5f6fa;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 20rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.label {
|
||||
width: 140rpx;
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
background-color: #f5f6fa;
|
||||
border-radius: 10rpx;
|
||||
padding: 0 20rpx;
|
||||
font-size: 26rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.flex1 {
|
||||
flex: 1;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 80rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: 30rpx;
|
||||
color: #ffffff;
|
||||
margin-top: 8rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
height: 72rpx;
|
||||
width: 140rpx;
|
||||
font-size: 26rpx;
|
||||
margin-top: 0;
|
||||
background-color: #3b82f6;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
|
||||
.btn-control {
|
||||
background-color: #16a34a;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 26rpx;
|
||||
margin: 12rpx 0 16rpx;
|
||||
}
|
||||
|
||||
.status-idle {
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.status-err {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.status-busy {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.tips {
|
||||
font-size: 22rpx;
|
||||
color: #999999;
|
||||
line-height: 36rpx;
|
||||
margin-top: 8rpx;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tips-title {
|
||||
display: block;
|
||||
color: #16a34a;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.tips-mono {
|
||||
display: block;
|
||||
font-size: 20rpx;
|
||||
color: #0f172a;
|
||||
background-color: #f1f5f9;
|
||||
border-radius: 8rpx;
|
||||
padding: 12rpx;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #999999;
|
||||
font-size: 24rpx;
|
||||
text-align: center;
|
||||
padding: 40rpx 0;
|
||||
}
|
||||
|
||||
.dev-item {
|
||||
background-color: #f8fafc;
|
||||
border-radius: 12rpx;
|
||||
padding: 20rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.dev-head {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dev-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #1e293b;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 20rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 20rpx;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
.badge-on {
|
||||
background-color: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.badge-off {
|
||||
background-color: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.badge-role {
|
||||
background-color: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.dev-meta {
|
||||
font-size: 24rpx;
|
||||
color: #475569;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.dev-sn {
|
||||
font-size: 22rpx;
|
||||
color: #94a3b8;
|
||||
margin-top: 6rpx;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.dev-ops {
|
||||
margin-top: 12rpx;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.page-tips {
|
||||
padding: 0 8rpx 40rpx;
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 26rpx;
|
||||
color: #3b82f6;
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 设备类型(dev_type)→ 面板规格
|
||||
*
|
||||
* 这是 App 侧「按 dev_type 分发面板」的唯一注册表:device-panel 的模板与逻辑里
|
||||
* 不再出现任何品类专有的字面量(标题 / 单位 / 小数位 / 阈值文案),全部由
|
||||
* devSpecOf() 返回的规格驱动。
|
||||
*
|
||||
* 为什么是「规格分发」而不是「一个品类一份 uvue」:
|
||||
* 湿度 / 液位 / 气压 与温度**同构** —— 都是「若干位置(A/B/C)+ 一个数值 + 上下限」。
|
||||
* 复用同一份上报协议(pos[] / t,键名见 device-panel 的 normalizePositions)之后,
|
||||
* 服务端**零改动**,还能白拿结构化落库、曲线、云端阈值复核与配置下发;
|
||||
* 而复制整页只会让 1550 行 × N 份各自漂移,改一处逻辑要改 N 遍。
|
||||
* 若将来出现「交互形态根本不同」的品类(如开关/继电器:只有布尔状态、没有位置与阈值),
|
||||
* 再按 dev_type 调到独立页面 —— 钩子在 device-panel 的 onLoad 里(setDevType 之后)。
|
||||
*
|
||||
* 加一个新品类 = 加一个 SPEC_xxx 常量 + 在 devSpecOf() 里加一行关键词匹配,无需改动任何页面逻辑。
|
||||
*
|
||||
* ⚠️ 协议复用说明:服务端解析的 `t`(位置数值)对非温度品类就是「该位置的读数」,
|
||||
* `upper` / `lower` 就是上下限,`gap` 是位置内极差 —— 字段名带温度味,语义是通用的。
|
||||
*/
|
||||
|
||||
export interface DevSpec {
|
||||
key: string // 规格标识(调试/日志用)
|
||||
label: string // 面板标题,如「温度」
|
||||
unit: string // 单位后缀,'' = 无单位
|
||||
decimals: number // 数值小数位
|
||||
diffOn: boolean // 是否显示「位置内极差」这一路(多探头/多传感器场景才有意义)
|
||||
diffLabel: string // 极差文案
|
||||
diffTip: string // 配置弹窗里对极差字段的说明('' = 不显示)
|
||||
highText: string // 越上限的提示词('超温' / '超上限')
|
||||
highField: string // 配置弹窗表单标签(带单位)
|
||||
lowField: string
|
||||
gapField: string
|
||||
probeOn: boolean // 是否显示「逐探头明细」与「配置 N 路 / 总线认到 M 颗」比对(OneWire 总线专用)
|
||||
}
|
||||
|
||||
/** 温度 —— 当前在产型号,也是所有缺省行为的基准(改动它等于改动线上表现,慎动) */
|
||||
export const SPEC_TEMP: DevSpec = {
|
||||
key: 'temp',
|
||||
label: '温度',
|
||||
unit: '℃',
|
||||
decimals: 2,
|
||||
diffOn: true,
|
||||
diffLabel: '温差',
|
||||
diffTip: '温差阈值:同位置各探头极差 ≥ 该值即上报并告警(由探头侧计算)。',
|
||||
highText: '超温',
|
||||
highField: '上限(℃)',
|
||||
lowField: '下限(℃)',
|
||||
gapField: '温差阈值',
|
||||
probeOn: true
|
||||
}
|
||||
|
||||
/** 湿度(%RH 0~100) */
|
||||
export const SPEC_HUMID: DevSpec = {
|
||||
key: 'humid',
|
||||
label: '湿度',
|
||||
unit: '%RH',
|
||||
decimals: 1,
|
||||
diffOn: false,
|
||||
diffLabel: '湿度差',
|
||||
diffTip: '',
|
||||
highText: '超上限',
|
||||
highField: '上限(%RH)',
|
||||
lowField: '下限(%RH)',
|
||||
gapField: '差值阈值',
|
||||
probeOn: false
|
||||
}
|
||||
|
||||
/** 液位 / 水位(cm,位置即液位点) */
|
||||
export const SPEC_LEVEL: DevSpec = {
|
||||
key: 'level',
|
||||
label: '液位',
|
||||
unit: 'cm',
|
||||
decimals: 1,
|
||||
diffOn: false,
|
||||
diffLabel: '差值',
|
||||
diffTip: '',
|
||||
highText: '超上限',
|
||||
highField: '上限(cm)',
|
||||
lowField: '下限(cm)',
|
||||
gapField: '差值阈值',
|
||||
probeOn: false
|
||||
}
|
||||
|
||||
/** 气压(kPa) */
|
||||
export const SPEC_PRESS: DevSpec = {
|
||||
key: 'press',
|
||||
label: '气压',
|
||||
unit: 'kPa',
|
||||
decimals: 2,
|
||||
diffOn: false,
|
||||
diffLabel: '差值',
|
||||
diffTip: '',
|
||||
highText: '超上限',
|
||||
highField: '上限(kPa)',
|
||||
lowField: '下限(kPa)',
|
||||
gapField: '差值阈值',
|
||||
probeOn: false
|
||||
}
|
||||
|
||||
/** 兜底:未注册的品类不臆断单位,先按「多路数值」把数据显示出来 */
|
||||
export const SPEC_GENERIC: DevSpec = {
|
||||
key: 'generic',
|
||||
label: '读数',
|
||||
unit: '',
|
||||
decimals: 2,
|
||||
diffOn: false,
|
||||
diffLabel: '差值',
|
||||
diffTip: '',
|
||||
highText: '超上限',
|
||||
highField: '上限',
|
||||
lowField: '下限',
|
||||
gapField: '差值阈值',
|
||||
probeOn: false
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 dev_type 取面板规格
|
||||
*
|
||||
* 关键词匹配而非全等匹配:型号后缀(_v2 / _pro)与厂商前缀(espressif_)都不必再登记一次。
|
||||
* **顺序即优先级**,先匹配者先返回 —— 温湿度一体的型号含 temp,会按温度展示。
|
||||
*
|
||||
* @param devType 设备注册时上报的类型(iot_device.dev_type)
|
||||
*/
|
||||
export function devSpecOf(devType: string | null): DevSpec {
|
||||
const raw = devType == null ? '' : devType
|
||||
const s = raw.trim().toLowerCase()
|
||||
/* 空类型 = 老设备 / 从未声明 → 温度(当前唯一的基准行为,向后兼容) */
|
||||
if (s.length === 0) {
|
||||
return SPEC_TEMP
|
||||
}
|
||||
if (s.indexOf('temp') >= 0) {
|
||||
return SPEC_TEMP
|
||||
}
|
||||
if (s.indexOf('hum') >= 0) {
|
||||
return SPEC_HUMID
|
||||
}
|
||||
if (s.indexOf('level') >= 0 || s.indexOf('liqu') >= 0 || s.indexOf('depth') >= 0) {
|
||||
return SPEC_LEVEL
|
||||
}
|
||||
if (s.indexOf('press') >= 0 || s.indexOf('baro') >= 0) {
|
||||
return SPEC_PRESS
|
||||
}
|
||||
return SPEC_GENERIC
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* MqttClient.uts
|
||||
* ------------------------------------------------------------------
|
||||
* uni-app x (uvue) 最小 MQTT 3.1.1 over WebSocket 客户端
|
||||
*
|
||||
* 依赖:uni.connectSocket / SocketTask 二进制收发(App 平台 4.61+ 支持 ArrayBuffer)
|
||||
*
|
||||
* 用法:
|
||||
* import { MqttClient } from './MqttClient.uts'
|
||||
*
|
||||
* const client = new MqttClient({
|
||||
* url: 'ws://192.168.1.10:8083', // 后台「服务设置」里 WebSocket 端口,默认 8083
|
||||
* clientId: 'app-' + Date.now().toString(),
|
||||
* username: '会员账号', // allow_member_login 开启时用框架会员账号;否则用后台「认证账号」
|
||||
* password: '密码',
|
||||
* autoReconnect: true
|
||||
* })
|
||||
* client.onConnAck = (code: number) => { console.log('连接结果 code=' + code) } // 0=成功
|
||||
* client.onMessage = (topic: string, payload: string) => { console.log(topic, payload) }
|
||||
* client.onSubAck = (topic: string, code: number) => { console.log('订阅结果', topic, code) } // 128=被拒
|
||||
* client.connect()
|
||||
* client.subscribe('/devices/+/state', 0)
|
||||
* client.publish('/devices/DEV001/cmd', '{"on":true}', 0, false)
|
||||
* client.disconnect()
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export interface MqttClientOptions {
|
||||
/** WebSocket 地址,形如 ws://192.168.1.10:8083 */
|
||||
url: string
|
||||
/** 客户端 ID,需全局唯一,建议 app-时间戳-随机数 */
|
||||
clientId: string
|
||||
/** MQTT 账号:allow_member_login=1 时为框架会员账号,否则为后台「认证账号」;匿名连接可留空 */
|
||||
username?: string | null
|
||||
password?: string | null
|
||||
/** 心跳间隔秒,默认 10(App 后台 JS 定时器会暂停,缩短心跳可让服务端更快判定掉线、回前台更快重连) */
|
||||
keepalive?: number
|
||||
/** 会话清除,默认 true */
|
||||
cleanSession?: boolean
|
||||
/** 断线自动重连,默认 true(指数退避 1s~30s) */
|
||||
autoReconnect?: boolean
|
||||
}
|
||||
|
||||
interface Packet {
|
||||
cmd: number
|
||||
length: number
|
||||
body: Uint8Array
|
||||
}
|
||||
|
||||
interface PublishInfo {
|
||||
topic: string
|
||||
payload: string
|
||||
}
|
||||
|
||||
export class MqttClient {
|
||||
private opt: MqttClientOptions
|
||||
private task: SocketTask | null = null
|
||||
private connected: boolean = false
|
||||
private closedByUser: boolean = false
|
||||
private pingTimer: number | null = null
|
||||
private reconnectTimer: number | null = null
|
||||
private retryMs: number = 1000
|
||||
private rxBuf: Uint8Array = new Uint8Array(0)
|
||||
private subscribedTopics: string[] = []
|
||||
// SUBACK 只回 packetId,用两个平行数组记住「发出的订阅」以便把结果对回主题
|
||||
private subPendMid: number[] = []
|
||||
private subPendTopic: string[] = []
|
||||
|
||||
/** 连接结果回调:code=0 成功,1~5 见 MQTT CONNACK 返回码 */
|
||||
onConnAck: ((code: number) => void) | null = null
|
||||
/** 连接成功(CONNACK=0)后触发 */
|
||||
onOpen: (() => void) | null = null
|
||||
/** 收到消息回调 */
|
||||
onMessage: ((topic: string, payload: string) => void) | null = null
|
||||
/** 订阅结果回调:code=0/1/2 为授予的 QoS,128(0x80) 表示服务端拒绝(ACL/归属校验不通过) */
|
||||
onSubAck: ((topic: string, code: number) => void) | null = null
|
||||
/** 连接关闭回调 */
|
||||
onClose: ((code: number) => void) | null = null
|
||||
/** 错误回调 */
|
||||
onError: ((msg: string) => void) | null = null
|
||||
|
||||
constructor(options: MqttClientOptions) {
|
||||
this.opt = options
|
||||
if (this.opt.keepalive == null || this.opt.keepalive <= 0) {
|
||||
this.opt.keepalive = 60
|
||||
}
|
||||
if (this.opt.cleanSession == null) {
|
||||
this.opt.cleanSession = true
|
||||
}
|
||||
if (this.opt.autoReconnect == null) {
|
||||
this.opt.autoReconnect = true
|
||||
}
|
||||
this.rxBuf = new Uint8Array(0)
|
||||
this.subscribedTopics = []
|
||||
this.subPendMid = []
|
||||
this.subPendTopic = []
|
||||
this.pingTimer = null
|
||||
this.reconnectTimer = null
|
||||
this.retryMs = 1000
|
||||
this.connected = false
|
||||
this.closedByUser = false
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.connected
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.task != null) {
|
||||
return
|
||||
}
|
||||
this.closedByUser = false
|
||||
this.retryMs = 1000
|
||||
this.consoleLog('connecting ' + this.opt.url)
|
||||
const task = uni.connectSocket({
|
||||
url: this.opt.url,
|
||||
success: () => {
|
||||
},
|
||||
fail: (err) => {
|
||||
this.emitError('connect fail: ' + JSON.stringify(err))
|
||||
if (!this.closedByUser && this.opt.autoReconnect === true) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
})
|
||||
this.task = task
|
||||
task.onOpen(() => {
|
||||
this.handleOpen()
|
||||
})
|
||||
task.onMessage((res) => {
|
||||
this.handleMessage(res.data)
|
||||
})
|
||||
task.onError(() => {
|
||||
this.emitError('socket error')
|
||||
})
|
||||
task.onClose((res) => {
|
||||
this.handleClose(res.code)
|
||||
})
|
||||
}
|
||||
|
||||
/** 断开连接(不再自动重连) */
|
||||
disconnect(): void {
|
||||
this.closedByUser = true
|
||||
this.connected = false
|
||||
this.stopPing()
|
||||
if (this.reconnectTimer != null) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
if (this.task != null) {
|
||||
try {
|
||||
// 发送 DISCONNECT(14)
|
||||
this.send(this.buildPacket(14, new Uint8Array(0)))
|
||||
} catch (e) {
|
||||
}
|
||||
this.task.close({ code: 1000, reason: 'bye' })
|
||||
this.task = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 立即重连(App 回前台时调用)。后台期间 JS 定时器暂停、心跳停发,连接可能已被服务端断开,
|
||||
* 此时不等指数退避定时器,直接清理旧连接并重新连接 */
|
||||
reconnectNow(): void {
|
||||
this.closedByUser = false
|
||||
this.retryMs = 1000
|
||||
if (this.reconnectTimer != null) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
if (this.task != null) {
|
||||
try {
|
||||
this.task.close({ code: 1000, reason: 'reconnect' })
|
||||
} catch (e) {
|
||||
}
|
||||
this.task = null
|
||||
}
|
||||
this.connected = false
|
||||
this.stopPing()
|
||||
this.connect()
|
||||
}
|
||||
|
||||
/** 订阅主题,qos 0 或 1 */
|
||||
subscribe(topic: string, qos: number = 0): void {
|
||||
if (topic.length === 0) {
|
||||
return
|
||||
}
|
||||
if (!this.connected) {
|
||||
this.emitError('not connected, cannot subscribe ' + topic)
|
||||
return
|
||||
}
|
||||
this.sendSubscribe(topic, qos)
|
||||
if (this.subscribedTopics.indexOf(topic) < 0) {
|
||||
this.subscribedTopics.push(topic)
|
||||
}
|
||||
}
|
||||
|
||||
/** 发布消息,retain 仅对设备命令慎用 */
|
||||
publish(topic: string, payload: string, qos: number = 0, retain: boolean = false): void {
|
||||
if (!this.connected) {
|
||||
this.emitError('not connected, cannot publish')
|
||||
return
|
||||
}
|
||||
const flags = ((qos & 0x03) << 1) | (retain ? 0x01 : 0x00)
|
||||
const body = this.join([this.encodeStr(topic), this.utf8Encode(payload)])
|
||||
const pkt = this.buildPacket(3, body)
|
||||
pkt[0] = (pkt[0] & 0xf0) | flags
|
||||
this.send(pkt)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 私有:WebSocket 生命周期
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private handleOpen(): void {
|
||||
// 打开后立刻发送 MQTT CONNECT(1)
|
||||
const body = this.buildConnect()
|
||||
this.send(this.buildPacket(1, body))
|
||||
}
|
||||
|
||||
private handleMessage(data: string | ArrayBuffer): void {
|
||||
let u8: Uint8Array
|
||||
if (data instanceof ArrayBuffer) {
|
||||
u8 = new Uint8Array(data)
|
||||
} else {
|
||||
u8 = this.utf8Encode(data)
|
||||
}
|
||||
this.rxBuf = this.join([this.rxBuf, u8])
|
||||
while (this.rxBuf.length > 0) {
|
||||
const pkt = this.parsePacket(this.rxBuf)
|
||||
if (pkt == null) {
|
||||
break
|
||||
}
|
||||
// 移除已消费字节
|
||||
const restLen = this.rxBuf.length - pkt.length
|
||||
const rest = new Uint8Array(restLen)
|
||||
for (let i = 0; i < restLen; i++) {
|
||||
rest[i] = this.rxBuf[pkt.length + i]
|
||||
}
|
||||
this.rxBuf = rest
|
||||
this.handleCmd(pkt.cmd, pkt.body)
|
||||
}
|
||||
}
|
||||
|
||||
private handleClose(code: number): void {
|
||||
this.connected = false
|
||||
this.stopPing()
|
||||
this.task = null
|
||||
if (this.onClose != null) {
|
||||
this.onClose(code)
|
||||
}
|
||||
if (!this.closedByUser && this.opt.autoReconnect === true) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer != null) {
|
||||
return
|
||||
}
|
||||
this.consoleLog('reconnect in ' + this.retryMs + 'ms')
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect()
|
||||
}, this.retryMs)
|
||||
this.retryMs = Math.min(this.retryMs * 2, 30000)
|
||||
}
|
||||
|
||||
private startPing(): void {
|
||||
this.stopPing()
|
||||
const ka = this.opt.keepalive != null ? this.opt.keepalive : 10
|
||||
const period = Math.max(10, Math.floor(ka / 2)) * 1000
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.connected) {
|
||||
// PINGREQ(12)
|
||||
this.send(this.buildPacket(12, new Uint8Array(0)))
|
||||
}
|
||||
}, period)
|
||||
}
|
||||
|
||||
private stopPing(): void {
|
||||
if (this.pingTimer != null) {
|
||||
clearInterval(this.pingTimer)
|
||||
this.pingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 私有:MQTT 报文
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private buildConnect(): Uint8Array {
|
||||
const vh: Uint8Array[] = []
|
||||
// 协议名 "MQTT"
|
||||
vh.push(new Uint8Array([0x00, 0x04]))
|
||||
vh.push(this.utf8Encode('MQTT'))
|
||||
// 协议级别 4(MQTT 3.1.1)
|
||||
vh.push(new Uint8Array([0x04]))
|
||||
// 连接标志
|
||||
const user = this.opt.username != null ? this.opt.username : ''
|
||||
const pass = this.opt.password != null ? this.opt.password : ''
|
||||
let flags = this.opt.cleanSession === true ? 0x02 : 0x00
|
||||
if (user.length > 0) {
|
||||
flags |= 0x80
|
||||
}
|
||||
if (pass.length > 0) {
|
||||
flags |= 0x40
|
||||
}
|
||||
const ka = this.opt.keepalive != null ? this.opt.keepalive : 10
|
||||
vh.push(new Uint8Array([flags, (ka >> 8) & 0xff, ka & 0xff]))
|
||||
// Payload:clientId + username + password
|
||||
vh.push(this.encodeStr(this.opt.clientId))
|
||||
if (user.length > 0) {
|
||||
vh.push(this.encodeStr(user))
|
||||
}
|
||||
if (pass.length > 0) {
|
||||
vh.push(this.encodeStr(pass))
|
||||
}
|
||||
return this.join(vh)
|
||||
}
|
||||
|
||||
private sendSubscribe(topic: string, qos: number): void {
|
||||
const mid = Math.floor(Math.random() * 65535) + 1
|
||||
const body = this.join([
|
||||
new Uint8Array([(mid >> 8) & 0xff, mid & 0xff]),
|
||||
this.encodeStr(topic),
|
||||
new Uint8Array([qos & 0x03])
|
||||
])
|
||||
const pkt = this.buildPacket(8, body)
|
||||
this.subPendMid.push(mid)
|
||||
this.subPendTopic.push(topic)
|
||||
// SUBSCRIBE 固定头固定为 0x82
|
||||
pkt[0] = 0x82
|
||||
this.send(pkt)
|
||||
}
|
||||
|
||||
private handleCmd(cmd: number, body: Uint8Array): void {
|
||||
if (cmd === 2) {
|
||||
// CONNACK
|
||||
const code = body.length >= 2 ? body[1] : -1
|
||||
if (code === 0) {
|
||||
this.connected = true
|
||||
this.retryMs = 1000
|
||||
// 恢复订阅
|
||||
for (let i = 0; i < this.subscribedTopics.length; i++) {
|
||||
this.sendSubscribe(this.subscribedTopics[i], 0)
|
||||
}
|
||||
this.startPing()
|
||||
if (this.onConnAck != null) {
|
||||
this.onConnAck(0)
|
||||
}
|
||||
if (this.onOpen != null) {
|
||||
this.onOpen()
|
||||
}
|
||||
} else {
|
||||
this.connected = false
|
||||
this.emitError('CONNACK reject code=' + code)
|
||||
if (this.onConnAck != null) {
|
||||
this.onConnAck(code)
|
||||
}
|
||||
this.disconnect()
|
||||
}
|
||||
} else if (cmd === 3) {
|
||||
// PUBLISH
|
||||
const p = this.parsePublish(body)
|
||||
if (p != null && this.onMessage != null) {
|
||||
this.onMessage(p.topic, p.payload)
|
||||
}
|
||||
} else if (cmd === 9) {
|
||||
// SUBACK:body = [packetIdHi, packetIdLo, code...]
|
||||
this.parseSubAck(body)
|
||||
}
|
||||
// PINGRESP(13) / PUBACK(4) 联调场景忽略
|
||||
}
|
||||
|
||||
/** 解析 SUBACK,把 code 对回到主题:128(0x80)=服务端拒绝订阅(ACL 未放行 / 非本人设备) */
|
||||
private parseSubAck(body: Uint8Array): void {
|
||||
if (body.length < 3) {
|
||||
return
|
||||
}
|
||||
const mid = (body[0] << 8) | body[1]
|
||||
const code = body[2]
|
||||
let idx = -1
|
||||
for (let i = 0; i < this.subPendMid.length; i++) {
|
||||
if (this.subPendMid[i] === mid) {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (idx < 0) {
|
||||
return
|
||||
}
|
||||
const topic = this.subPendTopic[idx]
|
||||
this.subPendMid.splice(idx, 1)
|
||||
this.subPendTopic.splice(idx, 1)
|
||||
// 注意:订阅被拒**不是**连接错误,不走 emitError,否则调用方会把连接标记为失败
|
||||
if (this.onSubAck != null) {
|
||||
this.onSubAck(topic, code)
|
||||
}
|
||||
}
|
||||
|
||||
private parsePublish(body: Uint8Array): PublishInfo | null {
|
||||
if (body.length < 2) {
|
||||
return null
|
||||
}
|
||||
const tlen = (body[0] << 8) | body[1]
|
||||
if (body.length < 2 + tlen) {
|
||||
return null
|
||||
}
|
||||
const topic = this.utf8Decode(this.sliceU8(body, 2, tlen))
|
||||
const payload = this.utf8Decode(this.sliceU8(body, 2 + tlen, body.length - 2 - tlen))
|
||||
return { topic: topic, payload: payload }
|
||||
}
|
||||
|
||||
private parsePacket(buf: Uint8Array): Packet | null {
|
||||
if (buf.length < 2) {
|
||||
return null
|
||||
}
|
||||
const cmd = (buf[0] >> 4) & 0x0f
|
||||
let multiplier = 1
|
||||
let remLen = 0
|
||||
let idx = 1
|
||||
let more = true
|
||||
while (more) {
|
||||
if (idx >= buf.length) {
|
||||
return null
|
||||
}
|
||||
const b = buf[idx]
|
||||
remLen += (b & 0x7f) * multiplier
|
||||
idx += 1
|
||||
multiplier *= 128
|
||||
more = (b & 0x80) !== 0
|
||||
if (multiplier > 128 * 128 * 128 * 128) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const total = idx + remLen
|
||||
if (buf.length < total) {
|
||||
return null
|
||||
}
|
||||
const body = this.sliceU8(buf, idx, remLen)
|
||||
return { cmd: cmd, length: total, body: body }
|
||||
}
|
||||
|
||||
private buildPacket(cmd: number, body: Uint8Array): Uint8Array {
|
||||
let len = body.length
|
||||
const rem: number[] = []
|
||||
do {
|
||||
let b = len % 128
|
||||
len = Math.floor(len / 128)
|
||||
if (len > 0) {
|
||||
b |= 0x80
|
||||
}
|
||||
rem.push(b)
|
||||
} while (len > 0)
|
||||
const header = new Uint8Array(rem.length + 1)
|
||||
header[0] = (cmd << 4) & 0xff
|
||||
for (let i = 0; i < rem.length; i++) {
|
||||
header[i + 1] = rem[i]
|
||||
}
|
||||
return this.join([header, body])
|
||||
}
|
||||
|
||||
private encodeStr(str: string): Uint8Array {
|
||||
const u8 = this.utf8Encode(str)
|
||||
const out = new Uint8Array(u8.length + 2)
|
||||
out[0] = (u8.length >> 8) & 0xff
|
||||
out[1] = u8.length & 0xff
|
||||
out.set(u8, 2)
|
||||
return out
|
||||
}
|
||||
|
||||
private send(data: Uint8Array): void {
|
||||
if (this.task == null) {
|
||||
return
|
||||
}
|
||||
const ab = new ArrayBuffer(data.length)
|
||||
const copy = new Uint8Array(ab)
|
||||
copy.set(data, 0)
|
||||
this.task.send({ data: ab })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 私有:字节工具(不依赖 TextEncoder,保证 uni-app x 兼容)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private join(parts: Uint8Array[]): Uint8Array {
|
||||
let total = 0
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
total += parts[i].length
|
||||
}
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
out.set(parts[i], off)
|
||||
off += parts[i].length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private sliceU8(buf: Uint8Array, start: number, len: number): Uint8Array {
|
||||
const out = new Uint8Array(len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
out[i] = buf[start + i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private utf8Encode(str: string): Uint8Array {
|
||||
const enc = encodeURIComponent(str)
|
||||
const bytes: number[] = []
|
||||
let i = 0
|
||||
while (i < enc.length) {
|
||||
const c = enc.charAt(i)
|
||||
if (c === '%') {
|
||||
bytes.push(parseInt(enc.substring(i + 1, i + 3), 16))
|
||||
i += 3
|
||||
} else {
|
||||
bytes.push(c.charCodeAt(0))
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
const out = new Uint8Array(bytes.length)
|
||||
for (let j = 0; j < bytes.length; j++) {
|
||||
out[j] = bytes[j]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private utf8Decode(bytes: Uint8Array): string {
|
||||
let s = ''
|
||||
let i = 0
|
||||
while (i < bytes.length) {
|
||||
const b = bytes[i]
|
||||
if (b < 0x80) {
|
||||
s += String.fromCharCode(b)
|
||||
i += 1
|
||||
} else if (b >= 0xc0 && b < 0xe0 && i + 1 < bytes.length) {
|
||||
s += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i + 1] & 0x3f))
|
||||
i += 2
|
||||
} else if (b >= 0xe0 && b < 0xf0 && i + 2 < bytes.length) {
|
||||
s += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i + 1] & 0x3f) << 6) | (bytes[i + 2] & 0x3f))
|
||||
i += 3
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
private emitError(msg: string): void {
|
||||
this.consoleLog('ERROR ' + msg)
|
||||
if (this.onError != null) {
|
||||
this.onError(msg)
|
||||
}
|
||||
}
|
||||
|
||||
private consoleLog(msg: string): void {
|
||||
console.log('[Mqtt] ' + msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 华为式设备绑定 -->
|
||||
<view class="card">
|
||||
<view class="card-title">华为式设备绑定(配网→激活→认领)</view>
|
||||
<view class="row">
|
||||
<text class="label">API地址</text>
|
||||
<input class="input" v-model="apiBase" placeholder="http://192.168.1.10" />
|
||||
</view>
|
||||
|
||||
<view class="sub-title">1. 会员登录(认领需 JWT)</view>
|
||||
<view class="row">
|
||||
<text class="label">账号</text>
|
||||
<input class="input" v-model="loginAccount" placeholder="会员账号" />
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">密码</text>
|
||||
<input class="input" v-model="loginPassword" placeholder="会员密码" />
|
||||
</view>
|
||||
<button class="btn btn-sm" @click="onLogin">登录获取凭证</button>
|
||||
<view class="status" :class="loginStatusClass">{{ loginStatusText }}</view>
|
||||
|
||||
<view class="sub-title">2. 设备激活(固件侧:入云领凭证)</view>
|
||||
<view class="row">
|
||||
<text class="label">SN</text>
|
||||
<input class="input" v-model="devSn" placeholder="设备序列号" />
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">激活令牌</text>
|
||||
<input class="input" v-model="activateToken" placeholder="出厂预置 activate_token" />
|
||||
</view>
|
||||
<button class="btn btn-sm" @click="onActivate">设备激活</button>
|
||||
|
||||
<view class="sub-title">3. 用户认领(App 输入配对码)</view>
|
||||
<view class="row">
|
||||
<text class="label">配对码</text>
|
||||
<input class="input" v-model="pairCode" placeholder="设备机身贴纸 6 位码" />
|
||||
</view>
|
||||
<button class="btn btn-sm" @click="onBind">绑定设备</button>
|
||||
<view class="tips">激活成功后 uuid/secret 自动填入下方连接表单,可直接连接设备 MQTT。</view>
|
||||
</view>
|
||||
|
||||
<!-- 连接设置 -->
|
||||
<view class="card">
|
||||
<view class="card-title">MQTT WebSocket 联调</view>
|
||||
<view class="row">
|
||||
<text class="label">服务器</text>
|
||||
<input class="input" v-model="serverUrl" placeholder="ws://192.168.1.10:8083" />
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">账号</text>
|
||||
<input class="input" v-model="username" placeholder="会员账号/认证账号,可留空" />
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">密码</text>
|
||||
<input class="input" v-model="password" placeholder="可留空" />
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">ClientID</text>
|
||||
<input class="input" v-model="clientId" placeholder="留空自动生成" />
|
||||
</view>
|
||||
<view class="status" :class="statusClass">{{ statusText }}</view>
|
||||
<button class="btn" :class="connected ? 'btn-danger' : 'btn-primary'" @click="onConnect">
|
||||
{{ connected ? '断开连接' : '连接服务器' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 订阅 -->
|
||||
<view class="card">
|
||||
<view class="card-title">订阅主题</view>
|
||||
<view class="row">
|
||||
<input class="input flex1" v-model="subTopic" placeholder="/devices/设备UUID/state" />
|
||||
<button class="btn btn-sm" @click="onSubscribe">订阅</button>
|
||||
</view>
|
||||
<view class="tips">
|
||||
设备状态:/devices/设备UUID/state(填自己的设备);告警推送:/notify/你的账号/#
|
||||
</view>
|
||||
<view class="tips">
|
||||
安全提示:服务端对 /devices/... 做归属校验,只能订阅已绑定或已共享给自己的设备;
|
||||
/devices/+/state 这类跨设备通配符默认拒绝,需管理员在「ACL规则」中显式放行。
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 发布 -->
|
||||
<view class="card">
|
||||
<view class="card-title">发布消息</view>
|
||||
<view class="row">
|
||||
<input class="input flex1" v-model="pubTopic" placeholder="/devices/DEV001/cmd" />
|
||||
</view>
|
||||
<view class="row">
|
||||
<input class="input flex1" v-model="pubPayload" placeholder='{"on":true}' />
|
||||
<button class="btn btn-sm" @click="onPublish">发送</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息流 -->
|
||||
<view class="card card-logs">
|
||||
<view class="card-title">
|
||||
<text>消息流</text>
|
||||
<text class="link" @click="clearLogs">清空</text>
|
||||
</view>
|
||||
<scroll-view class="logs" scroll-y="true">
|
||||
<view class="log" v-for="(item, idx) in logs" :key="idx">
|
||||
<text class="log-time">{{ item.time }}</text>
|
||||
<text class="log-topic">{{ item.topic }}</text>
|
||||
<text class="log-payload">{{ item.payload }}</text>
|
||||
</view>
|
||||
<view class="empty" v-if="logs.length === 0">暂无消息,先连接并订阅</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
|
||||
import { MqttClient } from './MqttClient.uts'
|
||||
|
||||
interface LogItem {
|
||||
time: string
|
||||
topic: string
|
||||
payload: string
|
||||
}
|
||||
|
||||
// 模块级单例:避免被 uni-app x 响应式代理包裹,保证类内部字节缓冲安全
|
||||
let mqtt: MqttClient | null = null
|
||||
|
||||
// ─── 响应式状态 ───────────────────────────────────
|
||||
const serverUrl = ref('ws://192.168.1.10:8083')
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const clientId = ref('')
|
||||
const subTopic = ref('/devices/你的设备UUID/state')
|
||||
const pubTopic = ref('/devices/你的设备UUID/cmd')
|
||||
const pubPayload = ref('{"on":true}')
|
||||
const connected = ref(false)
|
||||
const statusText = ref('未连接')
|
||||
const statusLevel = ref('idle')
|
||||
const logs = ref<LogItem[]>([])
|
||||
// 华为式绑定
|
||||
const apiBase = ref('http://192.168.1.10')
|
||||
const loginAccount = ref('')
|
||||
const loginPassword = ref('')
|
||||
const loginStatusText = ref('未登录')
|
||||
const loginStatusLevel = ref('idle')
|
||||
const accessToken = ref('')
|
||||
const devSn = ref('')
|
||||
const activateToken = ref('')
|
||||
const pairCode = ref('')
|
||||
|
||||
// ─── 计算属性 ───────────────────────────────────
|
||||
const loginStatusClass = computed<string>((): string => {
|
||||
if (loginStatusLevel.value === 'ok') {
|
||||
return 'status-ok'
|
||||
} else if (loginStatusLevel.value === 'err') {
|
||||
return 'status-err'
|
||||
} else if (loginStatusLevel.value === 'busy') {
|
||||
return 'status-busy'
|
||||
}
|
||||
return 'status-idle'
|
||||
})
|
||||
|
||||
const statusClass = computed<string>((): string => {
|
||||
if (statusLevel.value === 'ok') {
|
||||
return 'status-ok'
|
||||
} else if (statusLevel.value === 'err') {
|
||||
return 'status-err'
|
||||
} else if (statusLevel.value === 'busy') {
|
||||
return 'status-busy'
|
||||
}
|
||||
return 'status-idle'
|
||||
})
|
||||
|
||||
// ─── 生命周期 ───────────────────────────────────
|
||||
onLoad(() => {
|
||||
if (clientId.value.length === 0) {
|
||||
clientId.value = 'app-' + Date.now().toString()
|
||||
}
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
// App 后台期间 JS 定时器暂停、心跳停发,服务端可能已断开;回前台立即重连
|
||||
if (mqtt != null && !mqtt.isConnected) {
|
||||
statusText.value = '重新连接...'
|
||||
statusLevel.value = 'busy'
|
||||
mqtt.reconnectNow()
|
||||
}
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
teardownClient()
|
||||
})
|
||||
|
||||
// ─── HTTP 请求 ───────────────────────────────────
|
||||
function httpPost(path: string, body: UTSJSONObject, needToken: boolean, onOk: (resp: UTSJSONObject) => void, onFail: (msg: string) => void) {
|
||||
const header: UTSJSONObject = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
if (needToken && accessToken.value.length > 0) {
|
||||
header['Authorization'] = 'Bearer ' + accessToken.value
|
||||
}
|
||||
uni.request<UTSJSONObject>({
|
||||
url: apiBase.value + path,
|
||||
method: 'POST',
|
||||
data: body,
|
||||
header: header,
|
||||
success: (res: RequestSuccess<UTSJSONObject>) => {
|
||||
const resp = res.data
|
||||
const code = resp['code'] as number
|
||||
// 兼容插件 Result::success 的 message 键 与 框架 apiSuccess 的 msg 键
|
||||
const msg = (resp['message'] as string) || (resp['msg'] as string) || ''
|
||||
if (code === 0) {
|
||||
onOk(resp)
|
||||
} else {
|
||||
onFail(msg)
|
||||
}
|
||||
},
|
||||
fail: (err: RequestFail) => {
|
||||
onFail(err.errMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 动作 ───────────────────────────────────
|
||||
function onLogin() {
|
||||
if (loginAccount.value.length === 0 || loginPassword.value.length === 0) {
|
||||
loginStatusText.value = '请输入账号与密码'
|
||||
loginStatusLevel.value = 'err'
|
||||
return
|
||||
}
|
||||
loginStatusText.value = '登录中...'
|
||||
loginStatusLevel.value = 'busy'
|
||||
const body: UTSJSONObject = {
|
||||
'account': loginAccount.value,
|
||||
'password': loginPassword.value
|
||||
}
|
||||
httpPost('/api/v1/user/login', body, false, (resp: UTSJSONObject) => {
|
||||
accessToken.value = (resp['access_token'] as string) || ''
|
||||
if (accessToken.value.length === 0) {
|
||||
loginStatusText.value = '登录成功但未返回 token'
|
||||
loginStatusLevel.value = 'err'
|
||||
return
|
||||
}
|
||||
loginStatusText.value = '登录成功'
|
||||
loginStatusLevel.value = 'ok'
|
||||
logMsg('系统', '登录成功,access_token 已保存')
|
||||
}, (msg: string) => {
|
||||
loginStatusText.value = msg
|
||||
loginStatusLevel.value = 'err'
|
||||
})
|
||||
}
|
||||
|
||||
function onActivate() {
|
||||
if (devSn.value.length === 0 || activateToken.value.length === 0) {
|
||||
statusText.value = '请填写 SN 与激活令牌'
|
||||
statusLevel.value = 'err'
|
||||
return
|
||||
}
|
||||
statusText.value = '设备激活中...'
|
||||
statusLevel.value = 'busy'
|
||||
const body: UTSJSONObject = {
|
||||
'sn': devSn.value,
|
||||
'token': activateToken.value
|
||||
}
|
||||
httpPost('/iot/api/deviceActivate', body, false, (resp: UTSJSONObject) => {
|
||||
const data = resp['data'] as UTSJSONObject | null
|
||||
if (data == null) {
|
||||
statusText.value = '激活成功(无凭证返回)'
|
||||
statusLevel.value = 'ok'
|
||||
return
|
||||
}
|
||||
const uuid = (data['uuid'] as string) || ''
|
||||
const secret = (data['secret'] as string) || ''
|
||||
if (uuid.length > 0) {
|
||||
username.value = uuid
|
||||
password.value = secret
|
||||
clientId.value = uuid
|
||||
}
|
||||
statusText.value = secret.length > 0 ? '激活成功,凭证已填入连接表单' : '设备已激活(重复激活不下发凭证)'
|
||||
statusLevel.value = 'ok'
|
||||
logMsg('系统', '激活成功 sn=' + devSn.value + ' uuid=' + uuid + ' secret=' + secret)
|
||||
}, (msg: string) => {
|
||||
statusText.value = msg
|
||||
statusLevel.value = 'err'
|
||||
})
|
||||
}
|
||||
|
||||
function onBind() {
|
||||
if (accessToken.value.length === 0) {
|
||||
statusText.value = '请先登录会员'
|
||||
statusLevel.value = 'err'
|
||||
return
|
||||
}
|
||||
if (devSn.value.length === 0 || pairCode.value.length === 0) {
|
||||
statusText.value = '请填写 SN 与配对码'
|
||||
statusLevel.value = 'err'
|
||||
return
|
||||
}
|
||||
statusText.value = '绑定中...'
|
||||
statusLevel.value = 'busy'
|
||||
const body: UTSJSONObject = {
|
||||
'sn': devSn.value,
|
||||
'pair_code': pairCode.value
|
||||
}
|
||||
httpPost('/iot/api/client/bindDevice', body, true, (resp: UTSJSONObject) => {
|
||||
statusText.value = '绑定成功'
|
||||
statusLevel.value = 'ok'
|
||||
logMsg('系统', '绑定成功 sn=' + devSn.value)
|
||||
}, (msg: string) => {
|
||||
statusText.value = msg
|
||||
statusLevel.value = 'err'
|
||||
})
|
||||
}
|
||||
|
||||
function onConnect() {
|
||||
if (connected.value) {
|
||||
teardownClient()
|
||||
connected.value = false
|
||||
statusText.value = '已断开'
|
||||
statusLevel.value = 'idle'
|
||||
return
|
||||
}
|
||||
if (clientId.value.length === 0) {
|
||||
clientId.value = 'app-' + Date.now().toString()
|
||||
}
|
||||
statusText.value = '连接中...'
|
||||
statusLevel.value = 'busy'
|
||||
teardownClient()
|
||||
mqtt = new MqttClient({
|
||||
url: serverUrl.value,
|
||||
clientId: clientId.value,
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
autoReconnect: true
|
||||
})
|
||||
mqtt.onConnAck = (code: number) => {
|
||||
if (code === 0) {
|
||||
connected.value = true
|
||||
statusText.value = '已连接'
|
||||
statusLevel.value = 'ok'
|
||||
logMsg('系统', '连接成功')
|
||||
} else {
|
||||
statusText.value = '连接被拒 code=' + code
|
||||
statusLevel.value = 'err'
|
||||
logMsg('系统', '连接被拒 code=' + code)
|
||||
}
|
||||
}
|
||||
mqtt.onMessage = (topic: string, payload: string) => {
|
||||
logMsg(topic, payload)
|
||||
}
|
||||
mqtt.onClose = (code: number) => {
|
||||
connected.value = false
|
||||
statusText.value = '已断开 code=' + code
|
||||
statusLevel.value = 'idle'
|
||||
logMsg('系统', '连接关闭 code=' + code)
|
||||
}
|
||||
mqtt.onError = (msg: string) => {
|
||||
statusText.value = msg
|
||||
statusLevel.value = 'err'
|
||||
logMsg('系统', msg)
|
||||
}
|
||||
// 订阅结果:0x80 被拒(归属校验未通过 / ACL 未放行),如实反馈而不是静默
|
||||
mqtt.onSubAck = (topicName: string, code: number) => {
|
||||
if (code >= 128) {
|
||||
statusText.value = '订阅被拒绝(无权限)'
|
||||
statusLevel.value = 'err'
|
||||
logMsg('系统', '订阅被拒绝:' + topicName + '(设备未绑定到本账号 / 无共享 / ACL 未放行)')
|
||||
} else {
|
||||
logMsg('系统', '订阅成功:' + topicName)
|
||||
}
|
||||
}
|
||||
mqtt.connect()
|
||||
}
|
||||
|
||||
function onSubscribe() {
|
||||
if (mqtt == null || !connected.value) {
|
||||
statusText.value = '请先连接服务器'
|
||||
statusLevel.value = 'err'
|
||||
return
|
||||
}
|
||||
const topic = subTopic.value.trim()
|
||||
if (topic.length === 0) {
|
||||
return
|
||||
}
|
||||
mqtt.subscribe(topic, 0)
|
||||
logMsg('系统', '已订阅 ' + topic)
|
||||
}
|
||||
|
||||
function onPublish() {
|
||||
if (mqtt == null || !connected.value) {
|
||||
statusText.value = '请先连接服务器'
|
||||
statusLevel.value = 'err'
|
||||
return
|
||||
}
|
||||
const topic = pubTopic.value.trim()
|
||||
if (topic.length === 0) {
|
||||
return
|
||||
}
|
||||
mqtt.publish(topic, pubPayload.value, 0, false)
|
||||
logMsg('发送', topic + ' : ' + pubPayload.value)
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = [] as LogItem[]
|
||||
}
|
||||
|
||||
function logMsg(topic: string, payload: string) {
|
||||
const item: LogItem = {
|
||||
time: new Date().toLocaleTimeString(),
|
||||
topic: topic,
|
||||
payload: payload
|
||||
}
|
||||
logs.value.push(item)
|
||||
if (logs.value.length > 200) {
|
||||
logs.value.splice(0, logs.value.length - 200)
|
||||
}
|
||||
}
|
||||
|
||||
function teardownClient() {
|
||||
if (mqtt != null) {
|
||||
mqtt.onConnAck = null
|
||||
mqtt.onMessage = null
|
||||
mqtt.onClose = null
|
||||
mqtt.onError = null
|
||||
mqtt.onSubAck = null
|
||||
mqtt.disconnect()
|
||||
mqtt = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 20rpx;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.card-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.sub-title {
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
margin: 20rpx 0 12rpx;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.label {
|
||||
width: 180rpx;
|
||||
font-size: 26rpx;
|
||||
color: #333333;
|
||||
}
|
||||
.input {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
border: 1rpx solid #dddddd;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 16rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
.flex1 {
|
||||
flex: 1;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
.btn {
|
||||
height: 76rpx;
|
||||
border-radius: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
color: #ffffff;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.btn-sm {
|
||||
height: 64rpx;
|
||||
padding: 0 30rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 26rpx;
|
||||
margin-top: 0;
|
||||
display: inline-flex;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #2d8cf0;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #ed4014;
|
||||
}
|
||||
.status {
|
||||
font-size: 24rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 10rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
.status-ok {
|
||||
color: #19be6b;
|
||||
background: #e8f8f0;
|
||||
}
|
||||
.status-err {
|
||||
color: #ed4014;
|
||||
background: #fdeeea;
|
||||
}
|
||||
.status-busy {
|
||||
color: #2d8cf0;
|
||||
background: #eaf3fd;
|
||||
}
|
||||
.status-idle {
|
||||
color: #999999;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.tips {
|
||||
font-size: 22rpx;
|
||||
color: #999999;
|
||||
margin-top: 12rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.link {
|
||||
font-size: 24rpx;
|
||||
color: #2d8cf0;
|
||||
margin-left: auto;
|
||||
}
|
||||
.card-logs {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.logs {
|
||||
height: 400rpx;
|
||||
}
|
||||
.log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
.log-time {
|
||||
font-size: 20rpx;
|
||||
color: #999999;
|
||||
}
|
||||
.log-topic {
|
||||
font-size: 24rpx;
|
||||
color: #2d8cf0;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
.log-payload {
|
||||
font-size: 24rpx;
|
||||
color: #333333;
|
||||
margin-top: 4rpx;
|
||||
word-break: break-all;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #cccccc;
|
||||
font-size: 24rpx;
|
||||
padding: 40rpx 0;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user