/* 鹤仆云 H5 演示端 · 共用工具 * 纯原生,不引框架:老板电脑上双击就能开,不需要 npm。 */ const HB = (() => { // 家属端和后台端跑在同一个域下,localStorage 是共用的—— // 不加命名空间的话,在后台登录会把家属端的 token 顶掉。 let KEY = 'hb_family_token'; let UKEY = 'hb_family_user'; function setNS(ns) { KEY = 'hb_' + ns + '_token'; UKEY = 'hb_' + ns + '_user'; } async function req(method, path, body, opt = {}) { const headers = { 'Content-Type': 'application/json' }; const t = localStorage.getItem(KEY); if (t) headers.Authorization = 'Bearer ' + t; const res = await fetch(path, { method, headers, body: body === undefined || body === null ? undefined : JSON.stringify(body), }); let json = {}; try { json = await res.json(); } catch (e) { /* 空响应 */ } if (res.status === 401 && !opt.noRedirect) { localStorage.removeItem(KEY); location.hash = ''; location.reload(); throw new Error('登录已过期'); } if (json.code !== 0 && json.code !== undefined) { throw new Error(json.msg || json.message || '请求失败(' + res.status + ')'); } if (!res.ok) throw new Error('请求失败(' + res.status + ')'); return json.data; } const get = (p) => req('GET', p); const post = (p, b) => req('POST', p, b); const put = (p, b) => req('PUT', p, b); const del = (p) => req('DELETE', p); function setSession(token, user) { localStorage.setItem(KEY, token); localStorage.setItem(UKEY, JSON.stringify(user || {})); } function user() { try { return JSON.parse(localStorage.getItem(UKEY) || '{}'); } catch (e) { return {}; } } function logged() { return !!localStorage.getItem(KEY); } function logout() { localStorage.removeItem(KEY); localStorage.removeItem(UKEY); location.reload(); } /* ---------------- 展示辅助 ---------------- */ function ago(v) { if (!v) return '—'; const t = new Date(String(v).replace(' ', 'T')).getTime(); if (isNaN(t)) return String(v); const d = Math.floor((Date.now() - t) / 1000); if (d < 0) return '刚刚'; if (d < 60) return d + ' 秒前'; if (d < 3600) return Math.floor(d / 60) + ' 分钟前'; if (d < 86400) return Math.floor(d / 3600) + ' 小时前'; return Math.floor(d / 86400) + ' 天前'; } function dt(v) { if (!v) return '—'; return String(v).replace('T', ' ').slice(0, 19); } function hhmm(v) { if (!v) return '—'; return String(v).replace('T', ' ').slice(11, 16); } const TYPE = { DOOR: { icon: '🚪', name: '门磁', color: '#4a7ebb' }, VIB: { icon: '📳', name: '水振', color: '#7a6ad1' }, CURRENT: { icon: '⚡', name: '电流', color: '#d99a2b' }, WATER: { icon: '💧', name: '水浸', color: '#2e9bb5' }, SOS: { icon: '🆘', name: '紧急按钮', color: '#d9534f' }, SMOKE: { icon: '🔥', name: '烟感', color: '#c25a3c' }, }; const typeInfo = (t) => TYPE[t] || { icon: '📦', name: t || '设备', color: '#8a949e' }; const LEVEL = { S1: { name: '紧急', cls: 'lv1' }, S2: { name: '重要', cls: 'lv2' }, S3: { name: '提示', cls: 'lv3' }, }; const levelInfo = (l) => LEVEL[l] || { name: l || '—', cls: 'lv3' }; const STATUS = { NEW: '新告警', PUSHED: '已推送', CONFIRMED: '已确认', ESCALATED: '已升级', HANDLED: '已处置', CLOSED: '已关闭', FALSE: '误报', DELAYED: '免打扰', SUPERSEDED: '已被替代', }; // 这几种都不再"待处理":CLOSED 关闭、FALSE 误报、SUPERSEDED 已被新告警替代、 // HANDLED 家属已经点过"已处理"(后端 OPEN 里仍留 HANDLED 表示"处置未关闭", // 但家属视角点完就不该再挂角标,否则点了没反应会以为没生效。2026-11-09 修) const CLOSED = ['CLOSED', 'FALSE', 'SUPERSEDED', 'HANDLED']; const isOpen = (s) => !CLOSED.includes(s); const EVENT = { DOOR_OPEN: '门打开', DOOR_CLOSE: '门关闭', WATER_LEAK: '检测到漏水', WATER_LEAK_CLEAR: '漏水已解除', SOS_PRESS: '紧急按钮按下', SOS: '紧急呼叫', POWER_OFF: '设备断电', POWER_ON: '设备来电', MSG_UNDELIVERED: '补传:曾断开', POWER_CUT: '设备断电', POWER_RESTORE: '设备来电', PRE_ALARM_0X04: '断档告警', VIB_ALARM: '震动异常', CURRENT_ALARM: '电流异常', SMOKE: '烟感报警', SMOKE_CLEAR: '烟雾已散', SOS_CLEAR: '呼叫解除', WATER_ACTIVITY: '用水活动', WATER_ACTIVITY_STOP: '用水停止', HEARTBEAT: '心跳', }; const evName = (t) => EVENT[t] || t || '事件'; function toast(msg, kind) { let box = document.getElementById('hb-toast'); if (!box) { box = document.createElement('div'); box.id = 'hb-toast'; document.body.appendChild(box); } box.textContent = msg; box.className = 'show' + (kind === 'err' ? ' err' : ''); clearTimeout(box._t); box._t = setTimeout(() => { box.className = ''; }, 2400); } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); } function maskPhone(p) { if (!p || p.length < 7) return p || '—'; return p.slice(0, 3) + '****' + p.slice(-4); } /* ------------------------------------------------------------------ 命令状态回显 ------------------------------------------------------------------ 七态(J-00 V1.7 §3.4)翻成人话。家属端和后台共用一份,避免两边说法不一致。 为什么需要它:下发只是写一条 PENDING,真正到设备要等接入端来领(I-18), 设备回执再走 I-05。中间可能几秒,也可能几小时(设备离线)。 以前点完只弹"已排队",家属永远不知道成没成,按钮看着像摆设。 */ const CMD_TEXT = { PENDING: ['已排队,等设备连上后下发', '#b8860b'], CLAIMED: ['正在下发…', '#0a6ebd'], SENT: ['已发出,等设备回应', '#0a6ebd'], ACKED: ['设备已回应', '#1a7f37'], FAILED: ['设备没能执行', '#c0392b'], TIMEOUT: ['超时没有回应', '#c0392b'], CANCELLED: ['已取消', '#6b7784'], }; const CMD_DONE = ['ACKED', 'FAILED', 'TIMEOUT', 'CANCELLED']; /** * 盯一条命令,直到终态或轮询次数用完。 * * @param devId 设备 * @param commandNo 刚下发的命令号(拿不到就取最新一条) * @param boxId 显示状态的那个 div * @param times 轮询次数,默认 10 次 × 3 秒 */ async function watchCommand(devId, commandNo, boxId, times) { const max = times || 10; let n = 0; const tick = async () => { n++; let row = null; try { const list = await get('/api/v1/devices/' + devId + '/commands'); const arr = list || []; row = arr.find((x) => (x.command_no || x.commandNo) === commandNo) || arr[0]; } catch (e) { /* 查询失败保持上一行,不要打断用户 */ } const box = document.getElementById(boxId); if (!box) return; if (row) { const st = String(row.status || ''); const meta = CMD_TEXT[st] || [st, '#6b7784']; let html = '' + esc(meta[0]) + ''; if (row.result_msg) { html += ' · ' + esc(row.result_msg) + ''; } if (st === 'PENDING' && n > 1) { html += '(不用一直等,设备连上会自动下发)'; } box.innerHTML = html; if (CMD_DONE.includes(st)) return; } if (n < max) setTimeout(tick, 3000); }; tick(); } return { req, get, post, put, del, setNS, setSession, user, logged, logout, ago, dt, hhmm, typeInfo, levelInfo, STATUS, isOpen, evName, toast, esc, maskPhone, CMD_TEXT, CMD_DONE, watchCommand, }; })();