Files
hepu/demo/web/common.js
T
aomin a7605ef8b8 chore(repo): 导入 M-Z 业务底座 V1.9 基线并建立仓库规范
按周工交付的 SEC-Z-01 清理后源码建立 M-Z 主干,并按 Git 仓库惯例重排目录。

- aomin-gateway/  后端工程 87 文件,原样保留,路径与构建命令未变
- demo/           演示前端与设备模拟器 9 文件(原 02_前端与设备端),非正式三端
- docs/handover/  交付与安全清理材料 6 份,内容未改
- docs/contract/  J-00 规格、OpenAPI、内部五接口、状态机、Mock 样例
- docs/reference/ 指纹与校验清单,含 84 个源码文件的 sha256 清单

目录重排只动位置不动内容:源码树 84 文件哈希与交付一致,逐文件自校验通过;
12 条 Flyway 迁移与交付包内镜像逐字节一致。

未纳入:迁移镜像目录(与工程内一套重复,改留指纹)、target/、证据 raw、
应急恢复脚本、评审用虚构台账数据。

安全:SEC-Z-01 15 处明文已清零,导入前重新扫描无新增命中,本仓库无历史污染。
行尾按交付字节原样保存(.gitattributes 设 * -text),不做归一化以免整树摘要失效。
2026-11-13 17:32:37 +08:00

207 lines
8.1 KiB
JavaScript
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.
/* 鹤仆云 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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = '<span style="color:' + meta[1] + '">' + esc(meta[0]) + '</span>';
if (row.result_msg) {
html += '<span style="color:#98a4b0"> · ' + esc(row.result_msg) + '</span>';
}
if (st === 'PENDING' && n > 1) {
html += '<span style="color:#98a4b0">(不用一直等,设备连上会自动下发)</span>';
}
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,
};
})();