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),不做归一化以免整树摘要失效。
This commit is contained in:
aomin committed 2026-11-13 17:32:37 +08:00
commit a7605ef8b8
121 files changed
+23629

No files matched your search

+206
View File
@@ -0,0 +1,206 @@
/* 鹤仆云 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,
};
})();