Files
YwxAppThink/addon/iot/client/uvue/device-panel/index.uvue
T
yangwx 18101cf11a 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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

1576 lines
56 KiB
Plaintext
Raw 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.
<template>
<view class="page">
<!-- 设备信息 -->
<view class="card">
<view class="card-title">
<text class="dev-name">{{ info.title.length > 0 ? info.title : info.sn }}</text>
<text class="badge" :class="info.online ? 'badge-on' : 'badge-off'">{{ info.online ? '在线' : '离线' }}</text>
</view>
<view class="meta">{{ info.dev_type }}<text v-if="info.model.length > 0"> · {{ info.model }}</text><text v-if="info.ver.length > 0"> · v{{ info.ver }}</text></view>
<view class="meta-sn">uuid: {{ info.uuid }}</view>
<view class="status" :class="connState === 2 ? 'status-ok' : (connState === 3 ? 'status-err' : 'status-idle')">
{{ connText }}
</view>
</view>
<!-- 订阅被拒提示:连上了却收不到数据时直接给结论,避免靠猜 -->
<view class="warn" v-if="aclNotice.length > 0">{{ aclNotice }}</view>
<!-- MQTT 连接 -->
<view class="card">
<view class="card-title">实时连接(WebSocket)</view>
<view class="row">
<text class="label">WS地址</text>
<input class="input" v-model="wsUrl" placeholder="ws://192.168.1.10:8083" />
</view>
<view class="row">
<text class="label">账号</text>
<input class="input" v-model="mqttUser" placeholder="留空则自动使用登录账号" />
</view>
<view class="row">
<button class="btn btn-primary flex1" @click="onToggleConn">{{ connState === 2 ? '断开' : '连接' }}</button>
<button class="btn btn-gray flex1" @click="onRefresh">拉取状态</button>
</view>
<view class="tips">
连接使用一次性凭证(ticket):每次连接/重连前先调 mqttTicket 换取 60s 短时密码,不直接使用会员明文密码。
服务端对 /devices/... 做归属校验:只能连接已绑定到本账号、或被共享给自己的设备(与「启用ACL」开关无关)。
</view>
</view>
<!-- 当前值——骨架(位置/别名/阈值)来自服务端配置,数值来自 state 报文。
标题/单位/小数位等品类差异全部由 spec(dev_type → DevSpec)驱动,页面里不再写品类字面量 -->
<view class="card">
<view class="card-title">
<text>{{ spec.label }}</text>
<text class="badge" :class="cfgInfo.applied ? 'badge-on' : 'badge-off'">配置 v{{ cfgInfo.cfg_ver }}{{ cfgInfo.applied ? ' 已生效' : ' 待设备生效' }}</text>
</view>
<view v-if="positions.length === 0" class="tips">
暂无位置配置。请先在后台「设备管理 → 传感器」添加位置与阈值(配置真源在服务端,App/设备共用同一份),
或等待设备首次上报后自动生成默认配置。
</view>
<view class="pos" v-for="(p, idx) in positions" :key="p.i">
<view class="pos-head">
<text class="pos-name">{{ p.alias }}</text>
<text class="pos-temp" :class="p.alert ? 'pos-alert' : ''">{{ p.tempText }}</text>
</view>
<view class="pos-meta">{{ p.pinText }}<text v-if="spec.diffOn">{{ spec.diffLabel }} {{ p.diffText }} · </text>限值 {{ p.rangeText }}</view>
<view class="pos-meta" v-if="spec.probeOn">探头 {{ p.probeText }}</view>
<view class="pos-warn" v-if="p.warnText.length > 0">{{ p.warnText }}</view>
<view class="pos-meta">更新于 {{ p.timeText }}<text v-if="p.ivSec > 0">(该值有效至 +{{ p.ivSec }}s)</text></view>
<view class="row" style="margin-top:12rpx;">
<button class="btn btn-sm" @click="onEditCfg(p.i)">编辑</button>
<button class="btn btn-sm btn-outline" @click="onDelCfg(p.i)">移除</button>
</view>
</view>
<view class="row" style="margin-top:16rpx;">
<button class="btn btn-primary flex1" @click="onAddCfg">新增位置</button>
<button class="btn btn-gray flex1" @click="loadConfig">重新拉取配置</button>
</view>
<view class="tips">
配置真源在服务端:这里改完会 retained 下发到设备 /config,设备回 /ack 后标记「已生效」。
上限/下限填 0 表示该方向不告警<text v-if="spec.diffOn">;温差阈值 0 表示不启用温差告警</text>。
</view>
</view>
<!-- 配置编辑弹层 -->
<view class="mask" v-if="efShow">
<view class="dialog">
<view class="dialog-title">位置配置(位置 {{ efIndex + 1 }})</view>
<view class="row">
<text class="label">名称</text>
<input class="input" v-model="efAlias" placeholder="如 A 路 / 进水口" />
</view>
<view class="row" v-if="spec.probeOn">
<text class="label">探头数</text>
<input class="input" type="number" v-model="efProbes" placeholder="1~3" />
</view>
<view class="row">
<text class="label">{{ spec.highField }}</text>
<input class="input" type="number" v-model="efUpper" placeholder="0=不启用" />
</view>
<view class="row">
<text class="label">{{ spec.lowField }}</text>
<input class="input" type="number" v-model="efLower" placeholder="0=不启用" />
</view>
<view class="row" v-if="spec.diffOn">
<text class="label">{{ spec.gapField }}</text>
<input class="input" type="number" v-model="efGap" placeholder="0=不启用" />
</view>
<view class="tips" v-if="spec.diffOn">{{ spec.diffTip }}</view>
<view class="row dialog-btns">
<button class="btn btn-gray flex1" @click="closeEdit">取消</button>
<button class="btn btn-primary flex1" @click="onSaveCfg">保存并下发</button>
</view>
</view>
</view>
<!-- 设备状态 -->
<view class="card">
<view class="card-title">原始报文(订阅 topics.state)</view>
<view class="state-box">{{ statusJson.length > 0 ? statusJson : '暂无状态,设备未上报或未连接' }}</view>
</view>
<!-- 控制 -->
<view class="card">
<view class="card-title">控制(发布 topics.cmd)</view>
<view class="row">
<button class="btn btn-primary flex1" @click="onCmd('{"on":true}')">开</button>
<button class="btn btn-danger flex1" @click="onCmd('{"on":false}')">关</button>
<button class="btn btn-warm flex1" @click="onCmd('{"query":1}')">查询</button>
</view>
<view class="row cmd-row">
<input class="input flex1" v-model="cmdInput" placeholder='自定义命令 JSON,如 {"temp":26,"on":true}' />
<button class="btn btn-sm" @click="onCmd(cmdInput)">发送</button>
</view>
</view>
<!-- 日志 -->
<view class="card" v-if="logs.length > 0">
<view class="card-title">日志</view>
<view class="log-box">
<text class="log-line" v-for="(l, i) in logs" :key="i">{{ l }}</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { MqttClient } from '../mqtt-debug/MqttClient.uts'
import { devSpecOf, SPEC_TEMP } from '../common/DevTypeSpec.uts'
interface DeviceInfo {
id: number
sn: string
mac: string
uuid: string
dev_type: string
ver: string
title: string
model: string
online: boolean
topics: UTSJSONObject | null
}
// 模块级连接句柄:避免被 uni-app x 响应式代理包裹,保证类内部字节缓冲安全
let mqttClient: MqttClient | null = null
// 历史落库去重键:retained 报文在重连时会被重投一次,不去重会写重复点
let lastHistKey = ''
// ─── 响应式状态 ───────────────────────────────────
const apiBase = ref('http://192.168.1.10')
const accessToken = ref('')
const info = ref<DeviceInfo>({ id: 0, sn: '', mac: '', uuid: '', dev_type: '', ver: '', title: '', model: '', online: false, topics: null })
// 面板规格:由 dev_type 决定(标题/单位/小数位/阈值文案),见 ../common/DevTypeSpec.uts
// 初值取温度 —— 当前在产型号只有温度,这样首屏不会先闪一下别的文案,
// dev_type 到手后由 setDevType() 立即切成对应规格。
const spec = ref(SPEC_TEMP)
const wsUrl = ref('ws://192.168.1.10:8083')
const mqttUser = ref('')
const connState = ref(0) // 0未连接 1连接中 2已连接 3失败
const connText = ref('未连接')
const statusJson = ref('')
const cmdInput = ref('')
const closedByUser = ref(false) // 用户手动断开,停止自动重连
const reconnectCount = ref(0)
const reconnectTimer = ref(0)
const logs = ref<string[]>([])
// 订阅被服务端拒绝(SUBACK=0x80)时的可见提示:归属校验/ACL 未放行都走这里
const aclNotice = ref('')
let subDeniedCount = 0
// ─── 传感器配置 / 位置视图 ─────────────────────────
// 关键设计:配置(位置数/别名/阈值)来自服务端,state 报文只带数值。
// 这样设备离线时也能先画出骨架(值显示 --),不会开天窗。
interface CfgInfo {
cfg_ver: number
sync_ver: number
applied: boolean
}
interface PosView {
i: number
alias: string
probes: number
upper: number
lower: number
gap: number
enabled: number
// 以下为「已格式化」字段:uvue 模板对复杂表达式支持有限,统一在逻辑层算好
tempText: string
diffText: string
pinText: string // 形如 "GPIO4 · ";空串 = 还没拿到引脚(设备未上线/老固件)
rangeText: string
probeText: string
warnText: string
timeText: string
ivSec: number
alert: boolean
probeCount: number
}
const positions = ref<PosView[]>([])
const cfgInfo = ref<CfgInfo>({ cfg_ver: 0, sync_ver: 0, applied: false })
// 位置序号 → GPIO 引脚。两个来源,谁先到用谁(都是"板子上的事实",不会冲突):
// ① deviceConfig 接口的 pins —— v1.7.0 起设备不再把 pin 塞进每条 state,
// 改成上线时在 /status 报一次 {"online":1,"pins":[3,4,10]},服务端缓存后随接口下发;
// ② state 报文里的 pin —— ≤1.6.2 老固件仍逐条带,保底不能删。
// loadConfig 重建骨架后靠它把 GPIO 提示补回来,否则"改一次配置就有一阵子看不到引脚"。
// 定长 3(= MAX_POS),避免下标越界;0 = 未知。
const lastPins: number[] = [0, 0, 0]
// 骨架来源标记:设备列表接口已内联配置(sensors)时置 true
// ⇒ onPullState() 不再单独请求 deviceConfig(少一次往返,首屏骨架更快出)
// 旧服务端不返回 sensors、或该设备从未配过(数组为空)时保持 false,自动回退到 deviceConfig
let skeletonReady = false
function pinTextOf(i: number): string {
const pin = (i >= 0 && i < lastPins.length) ? lastPins[i] : 0
return pin > 0 ? 'GPIO' + pin.toString() + ' · ' : ''
}
/**
* 写入 GPIO 表(下标 = 位置序号),有变化时顺带刷新已渲染位置的提示文案
*
* ⚠️ 只做"补",不做"清":拿不到引脚时保持原样(宁可显示上一次的,也不要闪成空白)。
* @return 是否发生变化
*/
function applyPins(pins: Array<any>): boolean {
let changed = false
for (let k = 0; k < pins.length && k < lastPins.length; k++) {
const pv = pins[k] as number
if (pv > 0 && lastPins[k] !== pv) {
lastPins[k] = pv
changed = true
}
}
if (changed) {
for (let k = 0; k < positions.value.length; k++) {
positions.value[k].pinText = pinTextOf(positions.value[k].i)
}
}
return changed
}
// 编辑表单用独立 ref:模板 v-model 绑基本类型最稳(uvue 对 ref 对象属性 v-model 支持有限)
const efShow = ref(false)
const efIndex = ref(0)
const efAlias = ref('')
const efProbes = ref('1')
const efUpper = ref('0')
const efLower = ref('0')
const efGap = ref('0')
// ─── 生命周期 ───────────────────────────────────
onLoad((options) => {
// 从添加设备页带参进入:?uuid=xx&title=xx&dev_type=xx&model=xx&ver=xx&sn=xx
const o = (options as UTSJSONObject | null)
if (o != null) {
const uuid = (o['uuid'] as string) || ''
if (uuid.length > 0) {
info.value.uuid = uuid
info.value.title = (o['title'] as string) || ''
setDevType((o['dev_type'] as string) || '')
info.value.model = (o['model'] as string) || ''
info.value.ver = (o['ver'] as string) || ''
info.value.sn = (o['sn'] as string) || ''
info.value.online = (o['online'] as string) === '1'
}
}
const savedBase = uni.getStorageSync('mqtt_apiBase') as string | null
if (savedBase != null && (savedBase as string).length > 0) {
apiBase.value = savedBase as string
// 由 API 地址推导 WS 地址(http→ws,端口 8083)
wsUrl.value = apiBase.value.replace(/^http/, 'ws') + ':8083'
}
const savedToken = uni.getStorageSync('mqtt_token') as string | null
if (savedToken != null && (savedToken as string).length > 0) {
accessToken.value = savedToken as string
}
onLoadDevices()
})
onUnload(() => {
onDisconnect()
})
// ─── 日志与请求 ───────────────────────────────────
function addLog(line: string) {
logs.value.push('[' + new Date().toLocaleTimeString() + '] ' + line)
if (logs.value.length > 50) {
logs.value.shift()
}
}
function httpGet(path: string, onOk: (resp: UTSJSONObject) => void, onFail: (msg: string) => void) {
const header: UTSJSONObject = {}
if (accessToken.value.length > 0) {
header['Authorization'] = 'Bearer ' + accessToken.value
}
uni.request<UTSJSONObject>({
url: apiBase.value + path,
method: 'GET',
header: header,
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 httpPost(path: string, body: UTSJSONObject, onOk: (resp: UTSJSONObject) => void, onFail: (msg: string) => void) {
const header: UTSJSONObject = {
'Content-Type': 'application/json'
}
if (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
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 buildTopics(uuid: string): UTSJSONObject {
// 与服务端 DeviceService::deviceTopics 保持一致(兜底拼装)
const t: UTSJSONObject = {
'state': '/devices/' + uuid + '/state',
'status': '/devices/' + uuid + '/status',
'config': '/devices/' + uuid + '/config',
'ack': '/devices/' + uuid + '/ack',
'cmd': '/devices/' + uuid + '/cmd',
'ota': '/devices/' + uuid + '/ota',
'ota_report': '/devices/' + uuid + '/ota/report',
'event': '/devices/' + uuid + '/event'
}
return t
}
function topic(kind: string): string {
if (info.value.topics != null) {
const t = info.value.topics[kind] as string | null
if (t != null && (t as string).length > 0) {
return t as string
}
}
return (buildTopics(info.value.uuid)[kind] as string) || ''
}
/**
* 记录设备类型并同步面板规格 —— 这是「按 dev_type 分发面板」的唯一落点
*
* 若将来要做「一品类一页面」的真·页面级分发,钩子也在这里:
* if (spec.value.key === 'switch') { uni.redirectTo({ url: '../device-switch/index?uuid=' + info.value.uuid }); return }
* 但同构品类(温度/湿度/液位/气压)不必如此,理由见 DevTypeSpec.uts 顶部说明。
*/
function setDevType(t: string) {
info.value.dev_type = t
spec.value = devSpecOf(t)
}
function onLoadDevices() {
if (info.value.uuid.length === 0) {
// 未指定设备:拉列表选第一台
httpGet('/iot/api/client/myDevices', (resp: UTSJSONObject) => {
const data = resp['data'] as UTSJSONObject | null
const list = (data != null ? (data['list'] as Array<UTSJSONObject>) : null) || []
if (list.length > 0) {
const it = list[0]
info.value.id = (it['id'] as number) || 0
info.value.sn = (it['sn'] as string) || ''
info.value.mac = (it['mac'] as string) || ''
info.value.uuid = (it['uuid'] as string) || ''
setDevType((it['dev_type'] as string) || '')
info.value.ver = (it['ver'] as string) || ''
info.value.title = (it['title'] as string) || ''
info.value.model = (it['model'] as string) || ''
info.value.online = (it['online'] as boolean) || false
info.value.topics = it['topics'] as UTSJSONObject | null
// 列表已内联配置 ⇒ 立刻建骨架(值随后由影子/报文填),首屏不必再等一次请求
skeletonReady = applyListConfig(it)
}
onPullState()
}, (msg: string) => {
addLog('拉取设备失败:' + msg)
})
} else {
// 已带 uuid:从列表补全 topics
httpGet('/iot/api/client/myDevices', (resp: UTSJSONObject) => {
const data = resp['data'] as UTSJSONObject | null
const list = (data != null ? (data['list'] as Array<UTSJSONObject>) : null) || []
for (let i = 0; i < list.length; i++) {
const it = list[i]
if ((it['uuid'] as string) === info.value.uuid) {
info.value.online = (it['online'] as boolean) || false
setDevType((it['dev_type'] as string) || '')
info.value.ver = (it['ver'] as string) || ''
info.value.topics = it['topics'] as UTSJSONObject | null
// 同分支 1:列表内联的配置直接建骨架
skeletonReady = applyListConfig(it)
break
}
}
onPullState()
}, (msg: string) => {
addLog('拉取设备失败:' + msg)
})
}
}
// ─── MQTT 连接(一次性凭证模式) ───────────────────
function onToggleConn() {
if (connState.value === 2) {
onDisconnect()
return
}
if (info.value.uuid.length === 0) {
addLog('没有设备可连接')
return
}
// 一次性凭证模式:连接/重连前先 HTTP 换取 ticket,再以 username=会员账号、password=ticket 连接
doConnect()
}
// 每次连接/重连都重新换取 60s 有效的一次性凭证,不直接使用会员明文密码
function doConnect() {
closedByUser.value = false
connState.value = 1
connText.value = '换取凭证中...'
addLog('换取 MQTT 一次性凭证 ...')
const emptyBody: UTSJSONObject = {}
httpPost('/iot/api/client/mqttTicket', emptyBody, (resp: UTSJSONObject) => {
const data = resp['data'] as UTSJSONObject | null
const ticket = (data != null ? (data['ticket'] as string) : null) || ''
const account = (data != null ? (data['account'] as string) : null) || ''
if (ticket.length === 0) {
connState.value = 3
connText.value = '凭证换取失败'
addLog('换取凭证失败:ticket 为空(请检查登录态)')
return
}
if (mqttUser.value.length === 0 && account.length > 0) {
mqttUser.value = account
}
const user = mqttUser.value.length > 0 ? mqttUser.value : account
connText.value = '连接中...'
addLog('连接 ' + wsUrl.value + '(username=' + user + ')...')
const client = new MqttClient({
url: wsUrl.value,
clientId: 'app-panel-' + Date.now().toString(),
username: user,
password: ticket,
keepalive: 10,
autoReconnect: false // 自动重连由本页接管(重连前重新换取凭证)
})
client.onConnAck = (code: number) => {
if (code === 0) {
connState.value = 2
connText.value = '已连接,正在订阅设备主题'
reconnectCount.value = 0
aclNotice.value = ''
subDeniedCount = 0
addLog('连接成功,订阅设备主题')
client.subscribe(topic('state'), 0)
client.subscribe(topic('status'), 0) // 在线态(LWT 保留消息);低频上报后不能靠 state 判离线
client.subscribe(topic('event'), 0)
client.subscribe(topic('ota_report'), 0)
} else {
connState.value = 3
connText.value = '连接被拒绝(code=' + code + ')'
addLog('CONNACK 拒绝 code=' + code + '(检查账号密码是否有效、ticket 是否过期、账号是否被禁用)')
client.disconnect()
}
}
client.onMessage = (topicName: string, payload: string) => {
if (topicName === topic('state')) {
statusJson.value = payload
applyState(payload)
} else if (topicName === topic('status')) {
applyStatus(payload)
} else if (topicName === topic('event')) {
addLog('event: ' + payload)
} else if (topicName === topic('ota_report')) {
addLog('ota_report: ' + payload)
}
}
client.onClose = (code: number) => {
connState.value = 0
connText.value = '连接已断开'
addLog('连接关闭 code=' + code)
scheduleReconnect()
}
client.onError = (msg: string) => {
connState.value = 3
connText.value = '连接错误'
addLog('错误:' + msg)
}
// SUBACK 结果:≥128 表示服务端拒绝该主题(3.1.1 为 0x80,MQTT5 为 0x87 等)
client.onSubAck = (topicName: string, code: number) => {
if (code >= 128) {
subDeniedCount = subDeniedCount + 1
connText.value = '已连接(' + subDeniedCount.toString() + ' 个主题被拒绝)'
addLog('订阅被拒绝:' + topicName)
aclNotice.value = '订阅被拒绝(' + topicName + '):当前账号无权访问该设备。常见原因:设备未绑定到该账号、共享已被撤销、设备已解绑;匿名连接不享有设备权限。'
} else {
addLog('订阅成功:' + topicName + ' (QoS ' + code.toString() + ')')
}
}
client.connect()
mqttClient = client
}, (msg: string) => {
connState.value = 3
connText.value = '凭证换取失败'
addLog('换取凭证失败:' + msg)
})
}
// 断线自动重连:重连前重新换取凭证,最多 5 次、间隔 2s
function scheduleReconnect() {
if (closedByUser.value || reconnectCount.value >= 5) {
return
}
reconnectCount.value++
if (reconnectTimer.value > 0) {
clearTimeout(reconnectTimer.value)
}
reconnectTimer.value = setTimeout(() => {
reconnectTimer.value = 0
doConnect()
}, 2000)
}
function onDisconnect() {
closedByUser.value = true
if (reconnectTimer.value > 0) {
clearTimeout(reconnectTimer.value)
reconnectTimer.value = 0
}
reconnectCount.value = 0
if (mqttClient != null) {
mqttClient.disconnect()
mqttClient = null
}
connState.value = 0
connText.value = '未连接'
}
// ─── 控制与状态 ───────────────────────────────────
function onCmd(payload: string) {
const cmd = payload.trim()
if (cmd.length === 0) {
addLog('命令为空')
return
}
const cmdTopic = topic('cmd')
if (mqttClient != null && mqttClient.isConnected) {
// 优先走 MQTT 直发
mqttClient.publish(cmdTopic, cmd, 0, false)
addLog('cmd -> ' + cmdTopic + ' : ' + cmd)
} else {
// 未连接时走 HTTP sendCommand 兜底
const body: UTSJSONObject = {
'uuid': info.value.uuid,
'payload': cmd
}
httpPost('/iot/api/client/sendCommand', body, (resp: UTSJSONObject) => {
addLog('HTTP 下发成功:' + JSON.stringify(resp['data']))
}, (msg: string) => {
addLog('HTTP 下发失败:' + msg)
})
}
}
function onPullState() {
if (info.value.uuid.length === 0) {
return
}
// 骨架:列表接口已内联配置(DeviceService::attachSensorConfig)时无需再拉
// 旧服务端不返回 sensors / 该设备没配过 ⇒ applyListConfig() 返回 false ⇒ 这里自动回退
if (!skeletonReady) {
loadConfig()
}
loadShadow()
}
/** 手动「拉取状态」:配置与影子都强制重拉(后台刚改过配置时用;onPullState 会按需跳过配置) */
function onRefresh() {
loadConfig()
loadShadow()
}
/** 拉影子报文(服务端缓存的最新一条 state)+ 在线态 —— 骨架之外的「值」都从这里来 */
function loadShadow() {
httpGet('/iot/api/client/deviceState?uuid=' + info.value.uuid, (resp: UTSJSONObject) => {
const data = resp['data'] as UTSJSONObject | null
if (data != null) {
const shadow = data['shadow'] as string | null
if (shadow != null && (shadow as string).length > 0) {
statusJson.value = shadow as string
applyState(shadow as string) // 影子格式与 state 报文一致 → 同一套解析
}
const online = data['online'] as boolean | null
if (online != null) {
info.value.online = online as boolean
}
}
}, (msg: string) => {
addLog('拉取状态失败:' + msg)
// HTTP 侧的权限拒绝(无权查看该设备)与 MQTT 订阅被拒同因,一并给提示
if (msg.indexOf('无权') >= 0) {
aclNotice.value = '无权访问该设备:' + msg + ' 请确认设备已绑定到当前账号,或已被设备主人共享给自己。'
}
})
}
// ─── 配置(服务端是真源)+ 报文解析 + 落库 ─────────
function pad2(n: number): string {
return n < 10 ? '0' + n.toString() : n.toString()
}
/**
* 用一份「位置配置数组」重建骨架(positions)
*
* 两个来源共用这里(字段完全一致,都是 SensorConfigService::format 的出口):
* ① 设备列表接口内联的 sensors —— 进页面就能画骨架(离线/首屏友好,见 applyListConfig)
* ② deviceConfig 接口的 sensors —— 兜底与手动刷新
*
* @return 是否建出骨架(有位置才算;空数组 = 服务端没配过,调用方自行决定是否回退)
*/
function applyConfigSensors(arr: Array<UTSJSONObject>, cfgVer: number, syncVer: number, applied: boolean): boolean {
cfgInfo.value.cfg_ver = cfgVer
cfgInfo.value.sync_ver = syncVer
cfgInfo.value.applied = applied
const list: PosView[] = []
for (let k = 0; k < arr.length; k++) {
const s = arr[k]
/* 位置序号:服务端统一 `index`(2026-09-15);`i` 是旧键,继续兜底。
⚠️ 取不到时会静默变 0 ⇒ 多个位置挤在同一序号:影子数据全填到位置 0,
编辑任意位置还会把别的行一起改掉,保存后服务端按序号去重 ⇒ 3 个位置并成 1 个。 */
const ri = s['index']
const i = ri != null ? (ri as number) : ((s['i'] as number) || 0)
const probes = (s['probes'] as number) || 1
const upper = (s['upper'] as number) || 0
const lower = (s['lower'] as number) || 0
const gap = (s['gap'] as number) || 0
const enabled = (s['enabled'] as number) || 0
list.push({
i: i,
alias: (s['alias'] as string) || '',
probes: probes,
upper: upper,
lower: lower,
gap: gap,
enabled: enabled,
tempText: '--',
diffText: '--',
pinText: '',
rangeText: lower.toString() + '~' + upper.toString() + spec.value.unit + (gap > 0 && spec.value.diffOn ? '(' + spec.value.diffLabel + '≥' + gap.toString() + spec.value.unit + '告警)' : '') + (enabled === 0 ? '(已停用)' : ''),
probeText: '等待上报',
warnText: '',
timeText: '—',
ivSec: 0,
alert: false,
probeCount: 0
})
}
positions.value = list
// 补回 GPIO 提示(来源见 lastPins 注释;applyPins 只补不清,拿不到就留上次的)
for (let k = 0; k < positions.value.length; k++) {
positions.value[k].pinText = pinTextOf(positions.value[k].i)
}
// 骨架就绪后,把手上已有的影子立刻填回去(列表先到、报文后到时同理)
if (statusJson.value.length > 0) {
applyState(statusJson.value)
}
return arr.length > 0
}
/**
* 用「设备列表项」内联的配置建骨架
*
* 目的:进页面一次请求(myDevices)就能出骨架,不必等第二次往返 ⇒ 初始化简单、骨架屏好做。
* 服务端:DeviceService::attachSensorConfig() 在 visibleDevices() 里内联
* sensors / cfg_ver / sync_ver / applied。
*
* ⚠️ 列表里的 pins 是 iot_device 的 **CSV 字符串**("3,4,10"),与 deviceConfig 下发的 pins(数组)
* 不同型,故这里刻意不解析:GPIO 提示由 /status 保留消息(连上即到)或 deviceConfig 补回,
* applyPins 只补不清,不会把已有提示闪成空白。
*
* @return 是否已建出骨架(false = 列表没带配置(旧服务端/从未配置)⇒ 调用方回退 deviceConfig)
*/
function applyListConfig(it: UTSJSONObject): boolean {
const arr = (it['sensors'] as Array<UTSJSONObject>) || []
if (arr.length === 0) {
return false
}
const ok = applyConfigSensors(arr, (it['cfg_ver'] as number) || 0, (it['sync_ver'] as number) || 0, (it['applied'] as boolean) || false)
addLog('设备列表已带配置:' + arr.length.toString() + ' 个位置(v' + cfgInfo.value.cfg_ver.toString() + ',' + (cfgInfo.value.applied ? '设备已生效' : '设备待生效') + ')')
return ok
}
/** 拉取传感器配置:位置列表/别名/阈值。App 与设备读的是同一份(服务端 DB) */
function loadConfig() {
if (info.value.uuid.length === 0) {
return
}
httpGet('/iot/api/client/deviceConfig?uuid=' + info.value.uuid, (resp: UTSJSONObject) => {
const data = resp['data'] as UTSJSONObject | null
if (data == null) {
return
}
const arr = (data['sensors'] as Array<UTSJSONObject>) || []
applyConfigSensors(arr, (data['cfg_ver'] as number) || 0, (data['sync_ver'] as number) || 0, (data['applied'] as boolean) || false)
// 接口下发的总线 GPIO 表(v1.7.0+;老固件/老库没有则为空,靠 state 里的 pin 保底)
const cfgPins = (data['pins'] as Array<any>) || []
if (cfgPins.length > 0) {
applyPins(cfgPins)
}
addLog('配置已加载:' + arr.length.toString() + ' 个位置(v' + cfgInfo.value.cfg_ver.toString() + ',' + (cfgInfo.value.applied ? '设备已生效' : '设备待生效') + ')')
}, (msg: string) => {
addLog('加载配置失败:' + msg)
})
}
/**
* 在线态(/status 保留消息)
* ≤1.6.2 {"online":0/1}
* v1.7.0 {"online":1,"pins":[3,4,10]} —— 上线时顺带报一次总线 GPIO 表
* (pin 是编译期常量,不必每条 state 都带;见 lastPins 注释)
*/
function applyStatus(payload: string) {
// JSON.parseObject 非法 JSON 返回 null(不抛异常),因此用 null 判定即可
const o = JSON.parseObject(payload)
if (o == null) {
addLog('status 解析失败:' + payload)
return
}
const on = (o['online'] as number) || 0
info.value.online = on === 1
/* 顺手收下引脚表:设备一上线就能显示 GPIO,不必等下一次 deviceConfig 往返 */
const pins = (o['pins'] as Array<any>) || []
if (pins.length > 0) {
applyPins(pins)
}
addLog('在线态:' + (on === 1 ? '在线' : '离线'))
}
/** 归一化后的探头槽位:两版报文都收敛成这个形状,下游代码只认它 */
interface ProbeSlot {
id: string // ROM 短码;'' = 未识别身份
temp: number | null // null = 该槽无有效读数(不是 0℃,也不是"没插"的同义词)
}
/**
* 把各版报文的探头数据归一化成 [{id, temp}](版式判定只看键在不在,绝不比版本号)
* v1.7.0 pr[[id,temp]] —— 二元组(键名 probes → pr),其余同 v1.6.0
* v1.6.0 probes[[id,temp]] —— 二元组,下标即槽位;id 与读数解耦,temp=null 时 id 仍可能有值,
* 据此可区分「没插器件」与「插了但读数失败」
* v1.5.0 probes[{id,temp}] —— 同上,只是写成了对象
* ≤1.4.0 vs[]/ok[]/ids[] —— 三个等长数组按位对应;ok[j]=false 时 vs[j] 是占位 0、
* ids[j] 一律空串(旧版把身份也一起抹了,故区分不出上述两种)
* ⚠️ 判「有没有值」一律用 "!= null",不要用 length/真值判断:0.00℃ 是 DS18B20 的合法读数。
*/
function normalizeProbes(o: UTSJSONObject, isNew: boolean, isV16: boolean): ProbeSlot[] {
const out: ProbeSlot[] = []
if (isNew) {
// 键名:pr(v1.7.0)优先、probes(≤1.6.2)回落 —— 同构,只是键名压短
let ps = (o['pr'] as Array<any>) || []
if (ps.length === 0) {
ps = (o['probes'] as Array<any>) || []
}
for (let j = 0; j < ps.length; j++) {
const p = ps[j]
let pid = ''
let pt: number | null = null
if (isV16) {
const tup = p as Array<any>
if (tup != null && tup.length > 0 && tup[0] != null) {
pid = tup[0] as string
}
if (tup != null && tup.length > 1 && tup[1] != null) {
pt = tup[1] as number
}
} else {
const po = p as UTSJSONObject
if (po != null) {
const rawId = po['id']
if (rawId != null) {
pid = rawId as string
}
const rawT = po['temp']
if (rawT != null) {
pt = rawT as number
}
}
}
out.push({ id: pid, temp: pt })
}
return out
}
const vs = (o['vs'] as Array<number>) || []
const ok = (o['ok'] as Array<boolean>) || []
const ids = (o['ids'] as Array<string>) || []
for (let j = 0; j < vs.length; j++) {
const jok = j < ok.length ? ok[j] : false
const jid = j < ids.length ? (ids[j] as string) : ''
out.push({ id: jid, temp: jok ? vs[j] : null })
}
return out
}
/** 归一化后的位置:三种报文版式都收敛成这个形状,applyState 只认它 */
interface PosState {
i: number // 位置序号(身份,= 服务端 iot_sensor_cfg.pos_index)
pin: number // 该总线 GPIO;0 = 未知(v1.7.0+ 不再随 state 上报;≤1.6.2 老固件仍带)
v: boolean // 本位置有没有有效读数
t: number // 位置数值(= 该位置的读数:温度/湿度/液位…,v=false 时无意义)
df: number // 位置内极差
slots: ProbeSlot[] // 下标 == 总线槽位
}
/**
* 把各版 state 报文的位置数组归一化成 PosState[]
*
* v1.7.0 pos[下标] = {t, df, pr[[id,temp]]}
* ↑ 与 v1.6.0 同构,只是键名压到 2 字母(positions→pos、probes→pr),
* 并去掉了两个冗余键:顶层 uuid、每位置的 pin(后者改由 /status 报一次)
* v1.6.0 positions[下标] = {pin, t, df, probes[[id,temp]]}
* ↑ 位置化数组:**下标就是位置序号**,未启用的位置是 JSON null
* v1.5.0 positions[] = {index, pin, temp, temp_diff, probes[{id,temp}]}
* ≤1.4.0 data[] = {i, t, df, v, vs[], ok[], ids[]}
*
* 版式判定一律用「键在不在」,不看固件版本号:版本号可能忘了升,键不会撒谎。
* v1.5 / v1.6 / v1.7 走同一套「位置化数组」分支,靠 index 区分 v1.5 —— v1.5 每个位置都写
* index(int,永不为 null),v1.6 起这个键彻底删掉(身份改由数组下标承载)。
*/
function normalizePositions(obj: UTSJSONObject): PosState[] {
const out: PosState[] = []
// 数组键名:pos(v1.7.0)优先、positions(≤1.6.2)回落;data(≤1.4.0)为旧式
const rawPos = obj['pos']
const rawOld = obj['positions']
const hasNew = rawPos != null || rawOld != null
let raw: Array<any> = []
if (hasNew) {
raw = (rawPos != null ? (rawPos as Array<any>) : (rawOld as Array<any>)) || []
} else {
raw = (obj['data'] as Array<any>) || []
}
let v16 = false
if (hasNew) {
v16 = true
for (let k = 0; k < raw.length; k++) {
const e = raw[k]
if (e == null) {
continue // v1.6 的位置占位
}
const eo = e as UTSJSONObject
if (eo['index'] != null || eo['temp'] != null || eo['temp_diff'] != null) {
v16 = false // 出现 v1.5 专有键 → 整条按 v1.5 解
break
}
}
}
for (let k = 0; k < raw.length; k++) {
const e = raw[k]
if (e == null) {
continue // 位置化数组的 null 占位 = 该位置未启用
}
const o = e as UTSJSONObject
let i = k
if (!hasNew) {
i = (o['i'] as number) || 0
} else if (!v16) {
const ri = o['index']
if (ri == null) {
continue
}
i = ri as number
}
let v = false
let t = 0
let df = 0
if (hasNew && v16) {
const rt = o['t']
if (rt != null) {
v = true
t = rt as number
df = (o['df'] as number) || 0
}
} else if (hasNew) {
const rt = o['temp']
if (rt != null) {
v = true
t = rt as number
df = (o['temp_diff'] as number) || 0
}
} else {
v = (o['v'] as boolean) || false
t = (o['t'] as number) || 0
df = (o['df'] as number) || 0
}
out.push({
i: i,
pin: (o['pin'] as number) || 0,
v: v,
t: t,
df: df,
slots: normalizeProbes(o, hasNew, v16)
})
}
return out
}
/**
* 解析 state 报文并填值(只填值,不改骨架)
*
* v1.7.0(当前版式 —— 键名压到 2 字母 + 去掉两处冗余,结构同 v1.6.0):
* {ts, ver, rssi, iv, cv,
* pos:[{t, df, pr:[["A3F2", 25.94]]}, null]}
* - pos ⚠️ 数组下标就是位置序号(已没有 index),null = 该位置未启用
* - t / df 位置温度 / 位置内极差;null = 本位置无有效读数(不是 0℃)
* - pr[] 下标 == 总线槽位;元素是二元组 [身份, 温度],温度 null = 该槽读数失败
* - iv 本条数据覆盖的秒数(距设备上次上报)→ 前向填充用(旧名 interval)
* - cv 设备已生效的配置版本(旧名 cfg_ver)
* - ts NTP Unix 秒(0=未对时 → 用手机接收时间兜底)
* - 无 uuid 谁发的看主题(/devices/{uuid}/state)—— 本页本来就知道 uuid
* - 无 pin v1.7.0 起改为上线时在 /status 报一次,服务端缓存 + deviceConfig 下发
* (≤1.6.2 老固件仍会逐条带 pin,所以下面照旧解析,只是可能缺省为 0)
*
* v1.6.0(可读版式,已烧录的设备仍要能用):
* {uuid, …, interval, cfg_ver, positions:[{pin, t, df, probes:[["A3F2", 25.94]]}, null]}
* v1.5.0:{…, interval, positions:[{index, pin, temp, temp_diff, probes:[{id, temp}]}]}
* ≤1.4.0:{…, iv, data:[{i, t, df, v, vs[], ok[], ids[]}]}
*
* 各版全部在 normalizePositions() 里归一化,本函数只按归一化后的形状取值 ——
* 设备不必为 App 升级,App 也不必跟着设备版本走。
* ⚠️ 顶层 iv / cv 一律「新键 ?? 旧键」链式兜底,不要再按版式分支 ——
* 旧写法 (hasNew ? interval : iv) 在 v1.7.0 下会取到 0(键名换了、位置化数组还在)。
*/
function applyState(payload: string) {
const obj = JSON.parseObject(payload)
if (obj == null) {
addLog('state 解析失败:' + payload)
return
}
const list = normalizePositions(obj)
const ts = (obj['ts'] as number) || 0
const iv = (obj['iv'] as number) || ((obj['interval'] as number) || 0)
const cfgVer = (obj['cv'] as number) || ((obj['cfg_ver'] as number) || 0)
const nowMs = Date.now()
// 设备用的配置版本比本地旧 → 说明后台改过而设备还没收到,主动刷一次配置
if (cfgVer > 0 && cfgVer !== cfgInfo.value.cfg_ver) {
addLog('设备配置版本 v' + cfgVer.toString() + ' 与本地 v' + cfgInfo.value.cfg_ver.toString() + ' 不一致,重新拉取')
loadConfig()
}
for (let k = 0; k < list.length; k++) {
const ps = list[k]
const i = ps.i
// 引脚:纯展示属性,与位置身份(i)解耦。
// ⚠️ v1.7.0 起报文不再带 pin —— 这是 ≤1.6.2 老固件的保底路径;
// v1.7.0 设备的引脚来自 /status 上线报文或 deviceConfig 的 pins(见 applyPins)。
const pin = ps.pin
if (pin > 0 && i >= 0 && i < lastPins.length) {
lastPins[i] = pin
}
const v = ps.v
const t = ps.t
const df = ps.df
// 槽位:v1.6/v1.5 = 总线枚举到的探头数(尾部空槽已截),≤1.4.0 恒为 PROBE_MAX
const slots = ps.slots
// 逐探头明细:无读数的槽显示 --(绝不显示占位 0,否则会误读成"0℃")
// 带身份显示「A3F2 25.6」——仅靠下标无法分辨是哪颗,换探头后就追踪不到了
const parts: string[] = []
let okCount = 0
let noIdCount = 0
for (let j = 0; j < slots.length; j++) {
const st = slots[j].temp
// 身份未识别时退化成槽位号(P1/P2…),至少还能定位是哪一路;
// 真原因(没枚举到器件 / 该片 ROM 序列号全 0 的克隆片)看固件串口日志
const hasId = slots[j].id.length > 0
const tag = hasId ? slots[j].id + ' ' : ('P' + (j + 1).toString() + ' ')
if (!hasId) {
noIdCount++
}
if (st != null) {
parts.push(tag + st.toFixed(1))
okCount++
} else {
parts.push(tag + '--')
}
}
const joined = parts.join(' / ')
// 定位到骨架;若设备报了配置外的新位置(如默认配置尚未同步),就地补一个
let idx = -1
for (let n = 0; n < positions.value.length; n++) {
if (positions.value[n].i === i) {
idx = n
break
}
}
if (idx < 0) {
positions.value.push({
i: i, alias: '位置' + (i + 1).toString(), probes: slots.length,
upper: 0, lower: 0, gap: 0, enabled: 1,
tempText: '--', diffText: '--', pinText: '', rangeText: '配置待同步', probeText: '等待上报',
warnText: '该位置尚未在服务端配置(请后台「传感器」补充)', timeText: '—',
ivSec: 0, alert: false, probeCount: okCount
})
idx = positions.value.length - 1
}
const p = positions.value[idx]
// 「当前是否越限」是状态,不是「告警事件」:阈值来自服务端真源(与 loadConfig 拉取的
// 同一份 upper/lower/gap,见 iot_sensor_cfg),所以本地算与云端算结果必然一致,
// 不存在规则漂移;保留本地算是为了零延迟(等云端推送会让高亮慢半拍)。
// 「告警事件」(何时发生过、设备有没有漏报)由服务端 iot_alert 负责(含云端复核),
// App 只消费不判定。
const isAlert = v && ((p.upper > 0 && t > p.upper) || (t < p.lower && p.lower !== 0) || (p.gap > 0 && df >= p.gap))
// 时间轴:优先设备 NTP 绝对时间(App 历史库靠它),未对时则用手机接收时间
const useTs = ts > 0 ? ts : Math.floor(nowMs / 1000)
const d = new Date(useTs * 1000)
const timeText = pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds())
positions.value[idx].tempText = v ? t.toFixed(spec.value.decimals) + spec.value.unit : '--(无有效读数)'
positions.value[idx].diffText = v ? df.toFixed(spec.value.decimals) + spec.value.unit : '--'
positions.value[idx].pinText = pinTextOf(i)
positions.value[idx].probeText = (joined.length > 0 ? joined : '无') + '(' + okCount.toString() + '/' + slots.length.toString() + ' 有效)'
positions.value[idx].timeText = timeText
positions.value[idx].ivSec = iv
positions.value[idx].alert = isAlert
positions.value[idx].probeCount = okCount
// 槽位数 = 总线实际枚举到的探头数。v1.5.0 起不再恒等于 PROBE_MAX(尾部空槽已截),
// 所以这个比对现在才真正有用:「后台配了 N 路、总线只认到 M 颗」能直接看出来。
// probeOn=false 的品类(湿度/液位/气压)逐路独立传感器、不走 OneWire 枚举,
// 不报 pr[] 是正常的 ⇒ 绝不能拿它比对,否则会误报「配置 1 路、只认到 0 颗」。
positions.value[idx].warnText = (spec.value.probeOn && slots.length !== p.probes && p.probes > 0)
? '⚠ 配置 ' + p.probes.toString() + ' 路探头,总线只认到 ' + slots.length.toString() + ' 颗'
: ''
if (v && p.upper > 0 && t > p.upper) {
positions.value[idx].warnText = '⚠ ' + spec.value.highText + '(上限 ' + p.upper.toString() + spec.value.unit + ')'
}
// 坏点定位:把越限探头按身份列出(形如「A3F2 95.60℃」),
// 仅有下标时无法跨时间判断到底是哪一颗探头出了问题
const overs: string[] = []
for (let j = 0; j < slots.length; j++) {
const st = slots[j].temp
if (st == null) {
continue
}
const tag = slots[j].id.length > 0 ? slots[j].id + ' ' : ''
if ((p.upper > 0 && st > p.upper) || (p.lower !== 0 && st < p.lower)) {
overs.push(tag + st.toFixed(spec.value.decimals) + spec.value.unit)
}
}
if (overs.length > 0) {
positions.value[idx].warnText = '⚠ 探头越限:' + overs.join('、')
}
saveHistory(useTs, i, t, df, okCount, slots.length, v)
}
}
/**
* 本地兜底落库
*
* 服务端已把同一份上报解析为结构化时序(iot_sensor_point,经
* /iot/api/client/sensorSeries 查询):画曲线应优先用服务端数据 —— 换手机不丢、
* 多端一致、可跨设备统计。这里保留本地分片,仅作「断网 / 未登录」时的降级来源。
* 分片策略:按天一个 key(iot_hist_{uuid}_{YYYYMMDD}),避免单 key 反复读写整张大表。
*/
function saveHistory(ts: number, i: number, t: number, df: number, okCount: number, total: number, valid: boolean) {
if (info.value.uuid.length === 0 || ts <= 0) {
return
}
// retained 报文在重连/重新订阅时会被重投,同一条数据不能写两次
const hkey = ts.toString() + '-' + i.toString()
if (hkey === lastHistKey) {
return
}
lastHistKey = hkey
const d = new Date(ts * 1000)
const dayKey = 'iot_hist_' + info.value.uuid + '_' + d.getFullYear().toString() + pad2(d.getMonth() + 1) + pad2(d.getDate())
let lines: string[] = []
const old = uni.getStorageSync(dayKey) as string | null
if (old != null && (old as string).length > 0) {
const parsed = (old as string).split('\n')
if (parsed.length > 0) {
lines = parsed
}
}
// 行格式:ts,i,t,df,okCount,total,valid —— 便于事后直接画曲线
lines.push(ts.toString() + ',' + i.toString() + ',' + t.toFixed(2) + ',' + df.toFixed(2) + ',' + okCount.toString() + ',' + total.toString() + ',' + (valid ? '1' : '0'))
uni.setStorageSync(dayKey, lines.join('\n'))
if (lines.length <= 1) {
cleanupHistory()
}
}
/** 只保留最近 7 天分片(防止 App 存储无限膨胀) */
function cleanupHistory() {
try {
const infoS = uni.getStorageInfoSync()
const keys = infoS.keys
const prefix = 'iot_hist_' + info.value.uuid + '_'
const keep = 7
const mine: string[] = []
for (let k = 0; k < keys.length; k++) {
const kk = keys[k]
if (kk.indexOf(prefix) === 0) {
mine.push(kk)
}
}
mine.sort()
while (mine.length > keep) {
const del = mine.shift()
if (del != null) {
uni.removeStorageSync(del)
addLog('清理历史分片:' + del)
}
}
} catch (e) {
addLog('历史清理失败')
}
}
// ─── 配置编辑(改的是服务端真源,保存即 retained 下发设备) ───
// 保存为「全量覆盖」语义:提交时必须把未编辑的位置原样带回,否则会被删掉。
const CFG_MAX_POS = 3
const CFG_MAX_PROBE = 3
function openEdit(i: number, alias: string, probes: number, upper: number, lower: number, gap: number) {
efIndex.value = i
efAlias.value = alias
efProbes.value = probes.toString()
efUpper.value = upper.toString()
efLower.value = lower.toString()
efGap.value = gap.toString()
efShow.value = true
}
function onEditCfg(i: number) {
for (let k = 0; k < positions.value.length; k++) {
const p = positions.value[k]
if (p.i === i) {
openEdit(p.i, p.alias, p.probes, p.upper, p.lower, p.gap)
return
}
}
}
/** 新增一个位置:i 取未占用的小序号,先只填进本地列表,保存时才写服务端 */
function onAddCfg() {
if (positions.value.length >= CFG_MAX_POS) {
addLog('最多 ' + CFG_MAX_POS.toString() + ' 个位置')
return
}
let next = -1
for (let cand = 0; cand < CFG_MAX_POS; cand++) {
let used = false
for (let k = 0; k < positions.value.length; k++) {
if (positions.value[k].i === cand) {
used = true
break
}
}
if (!used) {
next = cand
break
}
}
if (next < 0) {
addLog('位置序号已用尽')
return
}
positions.value.push({
i: next, alias: '位置' + (next + 1).toString(), probes: 1,
upper: 0, lower: 0, gap: 0, enabled: 1,
tempText: '--', diffText: '--', pinText: '', rangeText: '待保存', probeText: '等待上报',
warnText: '', timeText: '—', ivSec: 0, alert: false, probeCount: 0
})
openEdit(next, '位置' + (next + 1).toString(), 1, 0, 0, 0)
}
/** 删除本地列表里的位置(保存时一并覆盖到服务端) */
function onDelCfg(i: number) {
for (let k = 0; k < positions.value.length; k++) {
if (positions.value[k].i === i) {
positions.value.splice(k, 1)
addLog('已移除位置 ' + (i + 1).toString() + '(点“保存配置”后生效)')
return
}
}
}
function closeEdit() {
efShow.value = false
}
function onSaveCfg() {
if (info.value.uuid.length === 0) {
return
}
const alias = efAlias.value.trim()
const probes = parseInt(efProbes.value)
const upper = parseFloat(efUpper.value)
const lower = parseFloat(efLower.value)
const gap = parseFloat(efGap.value)
if (alias.length === 0) {
addLog('位置名称不能为空')
return
}
if (isNaN(probes) || probes < 1 || probes > CFG_MAX_PROBE) {
addLog('探头数须为 1~' + CFG_MAX_PROBE.toString())
return
}
if (isNaN(upper) || isNaN(lower) || isNaN(gap) || gap < 0) {
addLog('上限/下限' + (spec.value.diffOn ? '/温差阈值' : '') + '须为数字,且阈值不小于 0')
return
}
if (upper !== 0 && lower !== 0 && lower >= upper) {
addLog('下限须小于上限(任一为 0 表示不启用该方向告警)')
return
}
if (positions.value.length === 0) {
addLog('至少保留 1 个位置')
return
}
// 全量提交(后端是覆盖语义):未编辑的位置原样带回去
const list: UTSJSONObject[] = []
for (let k = 0; k < positions.value.length; k++) {
const p = positions.value[k]
const isCur = p.i === efIndex.value
const o: UTSJSONObject = {}
o['index'] = p.i // 位置序号统一 `index`(服务端同时兼容旧键 i,但新代码只发 index)
o['alias'] = isCur ? alias : p.alias
o['probes'] = isCur ? probes : p.probes
o['upper'] = isCur ? upper : p.upper
o['lower'] = isCur ? lower : p.lower
o['gap'] = isCur ? gap : p.gap
o['enabled'] = p.enabled // 停用状态原样保留(App 面板不做启停开关)
list.push(o)
}
const body: UTSJSONObject = {
'uuid': info.value.uuid,
'sensors': JSON.stringify(list)
}
addLog('保存配置 ...')
httpPost('/iot/api/client/saveDeviceConfig', body, (resp: UTSJSONObject) => {
addLog('配置已保存并下发(设备离线则上线自动生效)')
closeEdit()
loadConfig()
}, (msg: string) => {
addLog('保存配置失败:' + msg)
})
}
</script>
<style>
.page {
padding: 24rpx;
background-color: #f5f6fa;
min-height: 100vh;
box-sizing: border-box;
}
.warn {
background-color: #fef3c7;
border-left: 8rpx solid #f59e0b;
border-radius: 12rpx;
padding: 18rpx 20rpx;
font-size: 22rpx;
color: #92400e;
line-height: 34rpx;
margin-bottom: 24rpx;
}
.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;
}
.dev-name {
font-size: 32rpx;
font-weight: bold;
color: #1e293b;
flex: 1;
}
.badge {
font-size: 20rpx;
padding: 4rpx 16rpx;
border-radius: 20rpx;
margin-left: 8rpx;
}
.badge-on {
background-color: #dcfce7;
color: #16a34a;
}
.badge-off {
background-color: #f1f5f9;
color: #94a3b8;
}
.meta {
font-size: 24rpx;
color: #475569;
margin-top: 8rpx;
}
.meta-sn {
font-size: 20rpx;
color: #94a3b8;
margin-top: 6rpx;
word-break: break-all;
}
.status {
font-size: 24rpx;
margin-top: 12rpx;
}
.status-ok {
color: #16a34a;
}
.status-err {
color: #dc2626;
}
.status-idle {
color: #999999;
}
.row {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 16rpx;
}
.label {
width: 130rpx;
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;
}
.btn {
height: 76rpx;
border-radius: 12rpx;
font-size: 28rpx;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
}
.btn-primary {
background-color: #3b82f6;
}
.btn-danger {
background-color: #ef4444;
}
.btn-warm {
background-color: #d97706;
}
.btn-gray {
background-color: #64748b;
}
.btn-sm {
height: 72rpx;
width: 140rpx;
font-size: 26rpx;
margin-left: 16rpx;
background-color: #3b82f6;
flex-shrink: 0;
}
.btn-outline {
background-color: #f1f5f9;
color: #64748b;
}
/* ─── 位置卡片(通用:温度/湿度/液位/气压共用) ─── */
.pos {
background-color: #f8fafc;
border-radius: 12rpx;
padding: 20rpx;
margin-bottom: 16rpx;
}
.pos-head {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.pos-name {
font-size: 28rpx;
font-weight: bold;
color: #1e293b;
}
.pos-temp {
font-size: 40rpx;
font-weight: bold;
color: #0f172a;
font-family: monospace;
}
.pos-alert {
color: #dc2626;
}
.pos-meta {
font-size: 22rpx;
color: #64748b;
margin-top: 8rpx;
line-height: 32rpx;
}
.pos-warn {
font-size: 22rpx;
color: #d97706;
margin-top: 8rpx;
line-height: 32rpx;
}
/* ─── 配置编辑弹层 ─── */
.mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(15, 23, 42, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 99;
}
.dialog {
width: 630rpx;
background-color: #ffffff;
border-radius: 16rpx;
padding: 28rpx;
}
.dialog-title {
font-size: 30rpx;
font-weight: bold;
color: #1e293b;
margin-bottom: 24rpx;
text-align: center;
}
.dialog-btns {
margin-top: 24rpx;
}
.cmd-row {
margin-top: 20rpx;
}
.state-box {
background-color: #f1f5f9;
border-radius: 12rpx;
padding: 20rpx;
font-size: 24rpx;
color: #0f172a;
font-family: monospace;
word-break: break-all;
min-height: 80rpx;
}
.tips {
font-size: 22rpx;
color: #999999;
line-height: 34rpx;
margin-top: 4rpx;
}
.log-box {
background-color: #0f172a;
border-radius: 12rpx;
padding: 16rpx;
}
.log-line {
display: block;
font-size: 20rpx;
color: #a5f3fc;
font-family: monospace;
line-height: 30rpx;
word-break: break-all;
}
</style>