Files
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

1111 lines
60 KiB
HTML
Raw Permalink 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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>鹤仆云 · 运营后台(H5 演示)</title>
<style>
:root{
--ink:#1b2430;--sub:#6b7784;--line:#e4eaef;--bg:#f2f5f8;--card:#fff;
--brand:#146b7a;--brand-l:#e8f2f4;--red:#d94b4b;--amber:#dd8a24;--green:#2f9e68;--side:#16262f;
}
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
background:var(--bg);color:var(--ink);font-size:14px}
.layout{display:flex;min-height:100vh}
/* ---------- 登录 ---------- */
#login{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
background:radial-gradient(circle at 30% 20%,#1d8fa3,#0e3d47 70%);z-index:50}
#login.hide{display:none}
.lg-card{background:#fff;border-radius:16px;padding:34px 32px;width:360px;box-shadow:0 24px 60px rgba(0,0,0,.28)}
.lg-card .mk{width:50px;height:50px;border-radius:14px;background:linear-gradient(150deg,#146b7a,#22a2a8);
display:flex;align-items:center;justify-content:center;font-size:24px;color:#fff;margin-bottom:16px}
.lg-card h2{margin:0 0 4px;font-size:20px}
.lg-card p{margin:0 0 22px;font-size:12.5px;color:var(--sub)}
.inp{width:100%;border:1px solid var(--line);border-radius:9px;padding:11px 13px;font-size:14px;
margin-bottom:11px;outline:none;transition:.15s}
.inp:focus{border-color:var(--brand);box-shadow:0 0 0 3px rgba(20,107,122,.1)}
.btn{border:0;border-radius:9px;padding:11px 18px;font-size:14px;font-weight:600;cursor:pointer;transition:.15s}
.btn:active{transform:scale(.99)}
.btn-pri{background:var(--brand);color:#fff;width:100%}
.btn-sm{padding:7px 13px;font-size:12.5px;font-weight:500}
.btn-line{background:#fff;border:1px solid var(--line);color:var(--ink)}
.tip{font-size:11.5px;color:var(--sub);text-align:center;margin-top:14px}
/* ---------- 侧栏 ---------- */
aside{width:206px;flex:0 0 206px;background:var(--side);color:#c8d5dc;display:flex;flex-direction:column}
.brand{padding:20px 18px 16px;font-size:15px;font-weight:600;color:#fff;letter-spacing:.4px;
border-bottom:1px solid rgba(255,255,255,.07)}
.brand small{display:block;font-size:10.5px;color:#7d93a0;font-weight:400;margin-top:3px;letter-spacing:0}
aside nav{flex:1;padding:10px 0}
aside nav a{display:flex;align-items:center;gap:9px;padding:10px 18px;font-size:13.5px;color:#c8d5dc;
cursor:pointer;border-left:3px solid transparent;text-decoration:none}
aside nav a:hover{background:rgba(255,255,255,.05);color:#fff}
aside nav a.on{background:rgba(29,143,163,.18);border-left-color:#37b3c6;color:#fff;font-weight:500}
aside nav a span{width:17px;text-align:center;font-size:14px}
.me{padding:13px 16px;border-top:1px solid rgba(255,255,255,.07);font-size:12px;display:flex;
align-items:center;justify-content:space-between;gap:8px}
.me b{color:#fff;font-weight:500}
.me button{background:rgba(255,255,255,.1);border:0;color:#c8d5dc;border-radius:7px;padding:5px 9px;
font-size:11.5px;cursor:pointer}
/* ---------- 主区 ---------- */
section{flex:1;min-width:0;display:flex;flex-direction:column}
.top{background:#fff;border-bottom:1px solid var(--line);padding:14px 24px;display:flex;
align-items:center;justify-content:space-between;gap:16px}
.top h1{margin:0;font-size:17px;font-weight:600}
.top .sub{font-size:12px;color:var(--sub);margin-top:2px}
main{flex:1;padding:18px 24px 40px;overflow-y:auto}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:12px;margin-bottom:18px}
.kpi{background:#fff;border-radius:12px;padding:15px 16px;border:1px solid var(--line)}
.kpi .l{font-size:12px;color:var(--sub);display:flex;justify-content:space-between}
.kpi .n{font-size:25px;font-weight:700;margin-top:6px;letter-spacing:-.5px}
.kpi .f{font-size:11.5px;color:var(--sub);margin-top:3px}
.kpi.red .n{color:var(--red)} .kpi.amber .n{color:var(--amber)} .kpi.green .n{color:var(--green)}
.panel{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden;margin-bottom:16px}
.panel-h{padding:13px 16px;border-bottom:1px solid var(--line);display:flex;align-items:center;
justify-content:space-between;gap:12px;flex-wrap:wrap}
.panel-h h3{margin:0;font-size:14.5px;font-weight:600}
.panel-h .tools{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
select,.srch{border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px;
background:#fff;outline:none;color:var(--ink)}
.srch{min-width:180px}
table{width:100%;border-collapse:collapse;font-size:13px}
th{background:#f8fafb;text-align:left;font-weight:600;color:#48566b;padding:9px 14px;
border-bottom:1px solid var(--line);white-space:nowrap;font-size:12.5px}
td{padding:10px 14px;border-bottom:1px solid #f0f4f6;vertical-align:middle}
tbody tr:hover td{background:#fafcfd}
tbody tr:last-child td{border-bottom:0}
.mono{font-family:Consolas,Menlo,monospace;font-size:12px;color:#43536b}
/* 设备编号(SN):客户手上、机身上印的就是这一串,必须比内部 dev_id 显眼。
点一下复制——运营对单、录单都要贴这串,手抄必错。2026-11-09 */
.sn{font-family:Consolas,Menlo,monospace;font-size:13px;font-weight:600;color:#16202e;
cursor:pointer;white-space:nowrap;user-select:all;
border-bottom:1px dashed #c3ced9;padding-bottom:1px;transition:color .12s,border-color .12s}
.sn:hover{color:#0b6bcb;border-bottom-color:#0b6bcb}
.sn.ok{color:#1f9d5b;border-bottom-color:#1f9d5b}
.badge{display:inline-block;font-size:11px;padding:2px 8px;border-radius:6px;font-weight:500;white-space:nowrap}
.b-s1{background:#fdeaea;color:#c0392b}
.b-s2{background:#fdf3e3;color:#b87a1f}
.b-s3{background:#eaf1fa;color:#3a6fa8}
.b-ok{background:#e9f5ef;color:#2f9e68}
.b-gray{background:#f1f4f6;color:#7b8894}
.b-blue{background:#e8f2f4;color:#146b7a}
.dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:5px}
.dot.on{background:var(--green)} .dot.off{background:#b9c3cb} .dot.unk{background:var(--amber)}
.empty{text-align:center;padding:44px;color:#9aa6b2;font-size:13px}
.empty .ic{font-size:30px;display:block;margin-bottom:8px;opacity:.5}
.link{color:var(--brand);cursor:pointer;text-decoration:none;font-weight:500}
.link:hover{text-decoration:underline}
/* 趋势条 */
.bars{display:flex;align-items:flex-end;gap:8px;height:96px;padding:12px 16px 0}
.bars .b{flex:1;background:linear-gradient(180deg,#37b3c6,#146b7a);border-radius:4px 4px 0 0;
min-height:4px;position:relative;transition:.3s}
.bars .b span{position:absolute;bottom:-19px;left:0;right:0;text-align:center;font-size:10.5px;color:var(--sub)}
.bars-wrap{padding-bottom:26px}
/* 弹层 */
#modal{position:fixed;inset:0;background:rgba(16,26,36,.42);display:none;align-items:center;
justify-content:center;z-index:40;padding:20px}
#modal.show{display:flex}
/* 弹窗拆成"头固定 + 体滚动":告警链一摊开内容就长,
整块滚动会把操作按钮顶到看不见的地方,小屏(1366×768)尤其明显 */
#modal .box{background:#fff;border-radius:14px;width:min(620px,100%);max-height:92vh;
display:flex;flex-direction:column;box-shadow:0 20px 50px rgba(0,0,0,.22)}
#modal .box-h{padding:16px 20px;border-bottom:1px solid var(--line);display:flex;
justify-content:space-between;align-items:center;flex:0 0 auto}
#modal .box-h h3{margin:0;font-size:16px}
#modal .box-h button{background:#f2f4f6;border:0;width:28px;height:28px;border-radius:8px;cursor:pointer}
#modal .box-b{padding:18px 20px 0;overflow-y:auto;min-height:0}
.kv{display:flex;padding:7px 0;border-bottom:1px solid var(--line);font-size:13.5px}
.kv:last-child{border-bottom:0}
.kv .k{color:var(--sub);width:120px;flex:0 0 auto}
.kv .v{flex:1;word-break:break-all}
/* 操作按钮吸底:告警链一摊开内容就长,按钮被顶到滚动区外,
运营得先滚到底才找得到"关闭"——和家属端同一个问题,同样处理 */
.acts{display:flex;gap:9px;margin-top:14px;flex-wrap:wrap;
position:sticky;bottom:0;background:#fff;z-index:3;padding:11px 0 16px;
box-shadow:0 -10px 14px -10px rgba(16,26,36,.2)}
.acts button{flex:1;min-width:100px;border:1px solid var(--line);background:#fff;border-radius:9px;
padding:10px;font-size:13.5px;cursor:pointer;font-weight:500}
.acts button.pri{background:var(--brand);color:#fff;border-color:var(--brand)}
.acts button.dan{border-color:#f3c7c7;color:var(--red)}
/* ---------- 告警链:同一风险连续触发的整组 ----------
列表里不展示被替代的单(那是噪音),但点开最新那条必须能看到整组——
运营复盘"门磁两小时报 41 次",要能看到那 41 条分别是在哪被顶掉的。 */
.chain{background:#f7fafb;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin:14px 0 0}
.chain-h{font-size:12px;color:#5c6b76;margin-bottom:8px;display:flex;align-items:baseline;gap:6px;
white-space:nowrap}
.chain-h b{color:var(--brand);font-size:14px}
.chain-h .r{margin-left:auto;font-family:Consolas,monospace;font-size:11.5px;color:#7b8894}
.chain-l{display:flex;flex-direction:column;gap:5px;max-height:132px;overflow-y:auto;
overscroll-behavior:contain;padding-right:3px}
/* 四列定宽,时间不会再被单号挤到换行 */
.ci{display:grid;grid-template-columns:106px 70px minmax(0,1fr) auto;align-items:center;gap:8px;
font-size:12.5px;color:#6b7784;padding:5px 9px;border-radius:7px;
background:#fff;border:1px solid #eef2f5}
.ci .ct{font-family:Consolas,Menlo,monospace;color:#334a55;font-size:12px;white-space:nowrap}
.ci .cs{color:#93a1ac;white-space:nowrap}
.ci .cn{color:#a8b4bd;font-family:Consolas,monospace;font-size:11.5px;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ci.sup{opacity:.6}
.ci.sup .ct{text-decoration:line-through;text-decoration-color:#c8d2d8}
.ci.cur{color:var(--brand);font-weight:600;border-color:#b9d8dd;background:#f2f9fa}
.ci.cur .cs{color:var(--brand)}
.ci .cm{background:#e6f2f4;color:var(--brand);border-radius:6px;
padding:1px 7px;font-size:11px;font-weight:600;white-space:nowrap}
/* 被替代的单不能单独处理,给一条直达最新的入口 */
.sup-tip{display:flex;align-items:center;gap:10px;background:#fff7e8;border:1px solid #f5dfb8;
color:#8a5a10;border-radius:9px;padding:9px 12px;font-size:13px;margin-bottom:12px}
.sup-tip button{margin-left:auto;flex:0 0 auto;background:#fff;border:1px solid #e8c98e;
color:#8a5a10;border-radius:7px;padding:5px 11px;font-size:12.5px;cursor:pointer;white-space:nowrap}
/* ---------- 数据范围编辑(按区域) ---------- */
.tip-bar{background:#f2f9fa;border:1px solid #cfe6e9;color:#2c5a63;border-radius:9px;
padding:9px 12px;font-size:12.5px;line-height:1.65;margin-bottom:12px}
.scope-tb{width:100%;border-collapse:collapse;font-size:12.5px}
.scope-tb th{text-align:left;font-weight:500;color:#6b7784;font-size:12px;
padding:6px 4px;border-bottom:1px solid var(--line);white-space:nowrap}
.scope-tb td{padding:5px 4px;border-bottom:1px solid #f2f5f7;vertical-align:middle}
.scope-tb select,.scope-tb input{width:100%;min-width:66px;border:1px solid var(--line);
border-radius:6px;padding:5px 6px;font-size:12.5px;background:#fff;color:var(--ink)}
.scope-tb .del{background:#fff;border:1px solid #f3c7c7;color:var(--red);border-radius:6px;
padding:4px 8px;font-size:12px;cursor:pointer;white-space:nowrap}
#hb-toast{position:fixed;left:50%;bottom:34px;transform:translate(-50%,12px);background:rgba(24,34,44,.93);
color:#fff;padding:9px 17px;border-radius:9px;font-size:13.5px;opacity:0;pointer-events:none;
transition:.22s;z-index:99}
#hb-toast.show{opacity:1;transform:translate(-50%,0)}
#hb-toast.err{background:rgba(190,58,58,.95)}
.sk{background:linear-gradient(90deg,#eef2f5 25%,#f7fafb 37%,#eef2f5 63%);background-size:400% 100%;
animation:sk 1.3s infinite;border-radius:6px;height:14px}
@keyframes sk{0%{background-position:100% 50%}100%{background-position:0 50%}}
/* ---- 新增表单 ---- */
.fin{width:100%;border:1px solid var(--line);border-radius:8px;padding:9px 11px;font-size:13.5px;
outline:none;margin-bottom:10px;color:var(--ink);background:#fff}
.fin:focus{border-color:var(--brand);box-shadow:0 0 0 3px rgba(20,107,122,.09)}
.flabel{font-size:12px;color:var(--sub);margin:2px 0 4px}
.frow{display:flex;gap:9px}
.frow .fin{flex:1;min-width:0}
.fbtn{width:100%;border:0;border-radius:9px;padding:11px;font-size:14px;font-weight:600;
background:var(--brand);color:#fff;cursor:pointer;margin-top:4px}
.fbtn:active{transform:scale(.99)}
</style>
</head>
<body>
<!-- ================= 登录 ================= -->
<div id="login">
<div class="lg-card">
<div class="mk">🏠</div>
<h2>鹤仆云 · 运营后台</h2>
<p>社区养老 · 守望 S 线一期</p>
<input class="inp" id="lgUser" placeholder="账号" value="admin">
<input class="inp" id="lgPwd" type="password" placeholder="密码">
<button class="btn btn-pri" onclick="doLogin()">登 录</button>
<div class="tip">演示账号 admin(口令按部署说明,不在页面内写死)</div>
</div>
</div>
<!-- ================= 主体 ================= -->
<div class="layout">
<aside>
<div class="brand">鹤仆云 · 运营后台<small>.work · 守望 S 线一期</small></div>
<nav id="menu"></nav>
<div class="me">
<div><b id="meName">—</b><br><span id="meRole" style="font-size:10.5px;color:#7d93a0">—</span></div>
<button onclick="HB.logout()">退出</button>
</div>
</aside>
<section>
<div class="top">
<div>
<h1 id="pgTitle">总览</h1>
<div class="sub" id="pgSub">—</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-line btn-sm" onclick="reload()">刷新</button>
<a class="btn btn-line btn-sm" href="../family/index.html" target="_blank" style="text-decoration:none">看家属端 ↗</a>
</div>
</div>
<main id="main"></main>
</section>
</div>
<div id="modal" onclick="if(event.target.id==='modal')closeModal()">
<div class="box"><div class="box-h"><h3 id="mTitle">详情</h3><button onclick="closeModal()">✕</button></div>
<div class="box-b" id="mBody"></div></div>
</div>
<script src="../common.js"></script>
<script>
HB.setNS('admin');
const MENU = [
['dash', '总览', '▦'], ['alarm', '告警中心', '🔔'], ['device', '设备管理', '📡'],
['home', '住户管理', '🏘'], ['user', '账号权限', '👤'], ['dict', '数据字典', '📖'],
['order', '工单', '🧾'], ['audit', '审计日志', '📋'],
];
const STATE = { page: 'dash', alarms: [], devices: [], households: [], users: [], alarmFilter: { status: '', level: '' }, keyword: '' };
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('menu').innerHTML = MENU.map(m =>
'<a data-k="' + m[0] + '" onclick="goto(\'' + m[0] + '\')"><span>' + m[2] + '</span>' + m[1] + '</a>').join('');
if (HB.logged()) start(); else document.getElementById('login').classList.remove('hide');
// ?tab=device|home|user|dash… 直接落到某个菜单:截验收图、往群里发某一页时用
const want = new URLSearchParams(location.search).get('tab');
if (want && MENU.some(m => m[0] === want)) setTimeout(() => goto(want), 400);
// ?open=register 直接把「设备入库」弹窗打开,演示/截图省一次点击
// ?open=<告警ID> 直接打开某条告警详情:复盘被替代的单、把某条告警的链接发给同事,都省一次点击
const open = new URLSearchParams(location.search).get('open');
if (open === 'register') {
setTimeout(() => { if (typeof openRegisterDevice === 'function') openRegisterDevice(); }, 900);
} else if (open && /^\d+$/.test(open)) {
setTimeout(() => {
goto('alarm');
setTimeout(() => openAlarm(Number(open)), 800);
}, 400);
}
document.getElementById('lgPwd').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
});
async function doLogin() {
const username = document.getElementById('lgUser').value.trim();
const password = document.getElementById('lgPwd').value;
try {
const d = await HB.post('/api/v1/auth/console/login', { username, password });
HB.setSession(d.token, d.user || { name: username, role: 'OPERATOR' });
start();
} catch (e) { HB.toast(e.message, 'err'); }
}
function start() {
document.getElementById('login').classList.add('hide');
const u = HB.user();
document.getElementById('meName').textContent = u.name || u.realName || u.username || 'admin';
document.getElementById('meRole').textContent = u.role || '运营';
goto('dash');
}
function goto(k) {
STATE.page = k;
document.querySelectorAll('#menu a').forEach(a => a.classList.toggle('on', a.dataset.k === k));
const t = MENU.find(m => m[0] === k);
document.getElementById('pgTitle').textContent = t ? t[1] : '';
render();
}
const reload = () => render();
async function render() {
const m = document.getElementById('main');
m.innerHTML = '<div class="sk" style="width:40%;height:18px"></div>'
+ '<div class="sk" style="width:100%;height:110px;margin-top:14px"></div>'
+ '<div class="sk" style="width:100%;height:180px;margin-top:14px"></div>';
try {
if (STATE.page === 'dash') await renderDash(m);
else if (STATE.page === 'alarm') await renderAlarms(m);
else if (STATE.page === 'device') await renderDevices(m);
else if (STATE.page === 'home') await renderHomes(m);
else if (STATE.page === 'user') await renderUsers(m);
else if (STATE.page === 'dict') await renderDicts(m);
else if (STATE.page === 'order') await renderOrders(m);
else await renderAudit(m);
} catch (e) {
m.innerHTML = '<div class="empty"><span class="ic">⚠️</span>' + HB.esc(e.message) + '</div>';
}
}
/* ---------------- 总览 ---------------- */
async function renderDash(m) {
const [alarms, devices, households, users] = await Promise.all([
HB.get('/api/v1/alarms?size=100').catch(() => ({ list: [] })),
HB.get('/api/v1/devices').catch(() => []),
HB.get('/api/v1/admin/households').catch(() => []),
HB.get('/api/v1/admin/users').catch(() => []),
]);
const A = alarms.list || alarms || [];
const D = devices || [];
const H = households || [];
const open = A.filter(a => HB.isOpen(a.status));
STATE.alarms = A;
const s1 = open.filter(a => a.level === 'S1');
const online = D.filter(d => d.online_status === 'ONLINE').length;
const rate = D.length ? Math.round(online / D.length * 100) : 0;
document.getElementById('pgSub').textContent =
'共 ' + H.length + ' 户 · ' + D.length + ' 台设备 · ' + A.length + ' 条告警记录';
let html = '<div class="kpis">'
+ kpi('待处理告警', open.length, s1.length ? '其中紧急 ' + s1.length + ' 条' : '无紧急', open.length ? 'red' : 'green')
+ kpi('设备在线率', rate + '%', online + ' / ' + D.length + ' 台在线', rate >= 90 ? 'green' : 'amber')
+ kpi('在册户数', H.length, '覆盖 ' + new Set(H.map(h => h.community_name).filter(Boolean)).size + ' 个小区', '')
+ kpi('后台账号', (users || []).length, '运营 / 物业', '')
+ '</div>';
// 近 7 天告警分布(用真实数据按天分组,没数据就画零柱)
const days = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(Date.now() - i * 86400000);
const key = d.toISOString().slice(0, 10);
days.push({ key, n: A.filter(a => String(a.trigger_at || '').slice(0, 10) === key).length });
}
const max = Math.max(1, ...days.map(d => d.n));
html += '<div class="panel"><div class="panel-h"><h3>近 7 天告警</h3>'
+ '<span style="font-size:12px;color:#6b7784">共 ' + days.reduce((s, d) => s + d.n, 0) + ' 条</span></div>'
+ '<div class="bars-wrap"><div class="bars">'
+ days.map(d => '<div class="b" style="height:' + Math.max(4, d.n / max * 88) + 'px" title="' + d.key + ':'
+ d.n + ' 条"><span>' + d.key.slice(5) + '</span></div>').join('')
+ '</div></div></div>';
html += '<div class="panel"><div class="panel-h"><h3>最新告警</h3>'
+ '<span class="link" onclick="goto(\'alarm\')">查看全部 →</span></div>'
+ (A.length ? '<table><thead><tr><th>级别</th><th>告警</th><th>户</th><th>设备</th><th>触发</th><th>状态</th><th></th></tr></thead><tbody>'
+ A.slice(0, 6).map(a => '<tr>'
+ '<td>' + lvBadge(a.level) + '</td>'
+ '<td><b>' + HB.esc(a.title) + '</b><div style="font-size:11.5px;color:#6b7784">' + HB.esc((a.content || '').slice(0, 34)) + '</div></td>'
+ '<td class="mono">' + (a.household_id || '—') + '</td>'
+ '<td>' + devCell(a) + '</td>'
+ '<td style="white-space:nowrap">' + HB.ago(a.trigger_at) + '</td>'
+ '<td>' + stBadge(a.status) + (a.cleared_at ? '<div style="font-size:11px;color:#2f9e68">已恢复</div>' : '') + '</td>'
+ '<td><span class="link" onclick="openAlarm(' + a.id + ')">处理</span></td></tr>').join('')
+ '</tbody></table>'
: '<div class="empty"><span class="ic">🌤</span>还没有告警记录</div>')
+ '</div>';
m.innerHTML = html;
}
function kpi(l, n, f, cls) {
return '<div class="kpi ' + cls + '"><div class="l"><span>' + l + '</span></div>'
+ '<div class="n">' + n + '</div><div class="f">' + f + '</div></div>';
}
const lvBadge = (l) => '<span class="badge b-' + (l === 'S1' ? 's1' : l === 'S2' ? 's2' : 's3') + '">'
+ (HB.levelInfo(l).name) + '</span>';
const stBadge = (s) => '<span class="badge ' + (HB.isOpen(s) ? 'b-s2' : 'b-ok') + '">'
+ (HB.STATUS[s] || s) + '</span>';
/* ---------------- 告警中心 ---------------- */
async function renderAlarms(m) {
const q = [];
if (STATE.alarmFilter.status) q.push('status=' + STATE.alarmFilter.status);
if (STATE.alarmFilter.level) q.push('level=' + STATE.alarmFilter.level);
q.push('size=200');
const r = await HB.get('/api/v1/alarms?' + q.join('&'));
const list = r.list || r || [];
STATE.alarms = list;
document.getElementById('pgSub').textContent = '共 ' + (r.total || list.length) + ' 条';
m.innerHTML = '<div class="panel"><div class="panel-h"><h3>告警列表</h3><div class="tools">'
+ '<select onchange="STATE.alarmFilter.status=this.value;render()">'
+ ['', 'OPEN', 'NEW', 'CONFIRMED', 'HANDLED', 'CLOSED', 'FALSE'].map(s =>
'<option value="' + s + '"' + (STATE.alarmFilter.status === s ? ' selected' : '') + '>'
+ (s ? (s === 'OPEN' ? '未处理' : HB.STATUS[s]) : '全部状态') + '</option>').join('')
+ '</select>'
+ '<select onchange="STATE.alarmFilter.level=this.value;render()">'
+ ['', 'S1', 'S2', 'S3'].map(s =>
'<option value="' + s + '"' + (STATE.alarmFilter.level === s ? ' selected' : '') + '>'
+ (s ? HB.levelInfo(s).name : '全部级别') + '</option>').join('')
+ '</select></div></div>'
+ (list.length ? '<table><thead><tr><th>单号</th><th>级别</th><th>告警</th><th>户</th><th>设备</th>'
+ '<th>触发时间</th><th>状态</th><th>操作</th></tr></thead><tbody>'
+ list.map(a => '<tr>'
+ '<td class="mono">' + HB.esc(a.alarm_no || '') + '</td>'
+ '<td>' + lvBadge(a.level) + '</td>'
+ '<td><b>' + HB.esc(a.title) + '</b><div style="font-size:11.5px;color:#6b7784">' + HB.esc((a.content || '').slice(0, 40)) + '</div></td>'
+ '<td>' + (a.household_id || '—') + '</td>'
+ '<td>' + devCell(a) + '</td>'
+ '<td style="white-space:nowrap">' + HB.dt(a.trigger_at) + '</td>'
+ '<td>' + stBadge(a.status) + (a.cleared_at ? '<div style="font-size:11px;color:#2f9e68">已恢复</div>' : '') + '</td>'
+ '<td><span class="link" onclick="openAlarm(' + a.id + ')">处理</span></td></tr>').join('')
+ '</tbody></table>'
: '<div class="empty"><span class="ic">✅</span>这个筛选下没有告警</div>')
+ '</div>';
}
/* 同组触发记录:像家属端一样把整组摊开,被替代的也列出来(后台要能复盘) */
function chainRow(ch, curId) {
if (!ch || !ch.items || ch.items.length <= 1) return '';
// 链里只写"月-日 时:分:秒",年份在这屏没有信息量,省下来的宽度留给单号
const shortT = v => { const s = HB.dt(v) || ''; return s.length > 10 ? s.slice(5) : s; };
const rows = ch.items.map(it => {
const me = Number(it.id) === Number(curId);
const sup = it.status === 'SUPERSEDED';
return '<div class="ci' + (me ? ' cur' : '') + (sup ? ' sup' : '') + '">'
+ '<span class="ct">' + shortT(it.trigger_at) + '</span>'
+ '<span class="cs">' + (HB.STATUS[it.status] || it.status) + '</span>'
+ '<span class="cn">' + HB.esc(it.alarm_no || '') + '</span>'
+ (me ? '<span class="cm">本条</span>' : '<span></span>') + '</div>';
}).join('');
return '<div class="chain">'
+ '<div class="chain-h">同组触发 <b>' + ch.count + '</b> 次'
+ '<span class="r">' + shortT(ch.firstAt) + ' → ' + shortT(ch.lastAt) + '</span></div>'
+ '<div class="chain-l">' + rows + '</div></div>';
}
async function openAlarm(id) {
// 走详情接口而不是列表缓存:列表默认不含被替代的单,缓存里也就没有它们
let a;
try { a = await HB.get('/api/v1/alarms/' + id); }
catch (e) { HB.toast(e.message, 'err'); return; }
if (!a) return;
const ch = a.chain || {};
const sup = a.status === 'SUPERSEDED';
let acts = '';
if (HB.isOpen(a.status) && !sup) {
// DELAYED = 免打扰时段落的单。后台(运营/物业)夜里本来就要值班处理,
// 所以它和 NEW 一样可确认、可处置 —— 后端白名单已同步(2026-11-10)。
if (['NEW', 'PUSHED', 'ESCALATED', 'DELAYED'].includes(a.status))
acts += '<button class="pri" onclick="act(' + id + ',\'confirm\')">确认接单</button>';
if (['NEW', 'PUSHED', 'CONFIRMED', 'ESCALATED', 'DELAYED'].includes(a.status))
acts += '<button onclick="act(' + id + ',\'handle\')">已处置</button>';
acts += '<button onclick="act(' + id + ',\'close\')">关闭</button>';
acts += '<button class="dan" onclick="act(' + id + ',\'false\')">标记误报</button>';
}
document.getElementById('mTitle').textContent = a.title;
document.getElementById('mBody').innerHTML =
(sup ? '<div class="sup-tip"><span>这条已被后来的告警替代,不能单独处理</span>'
+ '<button onclick="openAlarm(' + (ch.latestId || id) + ')">查看最新 →</button></div>' : '')
+ kv('单号', a.alarm_no) + kv('级别', HB.levelInfo(a.level).name) + kv('状态', HB.STATUS[a.status] || a.status)
+ kv('户 ID', a.household_id)
+ kv('设备', (a.device_name || '—') + (a.device_sn ? '(' + a.device_sn + ')' : ''))
+ kv('安装位置', a.device_location || a.install_location || '—')
+ kv('系统 ID', a.dev_id) + kv('规则', a.rule_key)
+ kv('来源事件', a.source_event_id ? ('#' + a.source_event_id) : '—')
+ kv('触发时间', HB.dt(a.trigger_at))
+ kv('已恢复', a.cleared_at
? HB.dt(a.cleared_at) + (a.clear_event_type ? '(' + HB.evName(a.clear_event_type) + ')' : '')
: '未恢复')
+ kv('内容', a.content)
+ chainRow(ch, id)
+ (acts ? '<div class="acts">' + acts + '</div>'
: '<div style="margin-top:14px;color:#8a949e;font-size:13px">'
+ (sup ? '已被替代,无需处理' : '这条已经处理完了') + '</div>');
document.getElementById('modal').classList.add('show');
// 链按时间升序,最新那条在最后;限高后它正好被裁在框底看不见。
// 打开就滚到"本条",否则每次都得自己往下滑才知道这次是最新的。
const chainBox = document.querySelector('#modal .chain-l');
if (chainBox) chainBox.scrollTop = chainBox.scrollHeight;
}
async function act(id, kind) {
const body = kind === 'false' ? { reason: '后台判定误报' } : { remark: '后台操作' };
try {
await HB.post('/api/v1/alarms/' + id + '/' + kind, body);
closeModal(); HB.toast('已' + ({ confirm: '确认', handle: '处置', close: '关闭', false: '标记误报' }[kind]));
render();
} catch (e) { HB.toast(e.message, 'err'); }
}
/* ---------------- 设备管理 ---------------- */
async function renderDevices(m) {
const [d, unreg] = await Promise.all([
HB.get('/api/v1/devices' + (STATE.keyword ? '?keyword=' + encodeURIComponent(STATE.keyword) : '')),
HB.get('/api/v1/admin/devices/unregistered').catch(() => []),
]);
const list = d || [];
STATE.devices = list;
const on = list.filter(x => x.online_status === 'ONLINE').length;
document.getElementById('pgSub').textContent = '共 ' + list.length + ' 台 · 在线 ' + on + ' 台'
+ ((unreg || []).length ? ' · 野设备 ' + unreg.length + ' 台' : '');
let html = '<div class="panel"><div class="panel-h"><h3>设备台账</h3><div class="tools">'
+ '<button class="btn btn-line btn-sm" onclick="openRegisterDevice()">+ 设备入库</button>'
+ '<input class="srch" placeholder="搜设备编号 SN / 名称" value="' + HB.esc(STATE.keyword) + '" '
+ 'onkeydown="if(event.key===\'Enter\'){STATE.keyword=this.value;render()}">'
+ '<button class="btn btn-line btn-sm" onclick="STATE.keyword=\'\';render()">重置</button>'
+ '</div></div>'
+ (list.length ? '<table><thead><tr><th>设备</th><th>设备编号(SN)</th><th>类型</th><th>厂家</th><th>户</th>'
+ '<th>安装位置</th><th>状态</th><th>电量/信号</th><th>最近上报</th><th></th></tr></thead><tbody>'
+ list.map(x => {
const t = HB.typeInfo(x.type_code);
return '<tr>'
+ '<td><b>' + HB.esc(x.display_name || t.name) + '</b><div class="mono">' + HB.esc(x.dev_id) + '</div></td>'
+ '<td>' + snCell(x.sensor_id) + '</td>'
+ '<td>' + t.icon + ' ' + HB.esc(t.name) + '</td>'
+ '<td>' + HB.esc(x.vendor || '—') + '</td>'
+ '<td>' + (x.household_id || '<span class="badge b-gray">未绑定</span>') + '</td>'
+ '<td>' + HB.esc(x.install_location || '—') + '</td>'
+ '<td>' + onlineBadge(x.online_status) + '</td>'
+ '<td class="mono">' + (x.battery_mv ? x.battery_mv + 'mV' : '—') + ' / ' + (x.csq || '—') + '</td>'
+ '<td style="white-space:nowrap">' + HB.ago(x.last_seen_at) + '</td>'
+ '<td><span class="link" onclick="openDevice(\'' + HB.esc(x.dev_id) + '\')">详情</span></td></tr>';
}).join('')
+ '</tbody></table>'
: '<div class="empty"><span class="ic">📡</span>没有设备</div>')
+ '</div>';
// 野设备:通电联网了但库里没有。运营在这儿补个类型就能收编。
html += '<div class="panel" style="margin-top:16px"><div class="panel-h"><h3>野设备'
+ ((unreg || []).length ? ' · 待核查 ' + unreg.length + ' 台' : '') + '</h3>'
+ '<span style="font-size:12px;color:#6b7784">'
+ '通电联网了、但库里没登记的机器。业主报"我家设备绑不上",先来这儿看看</span></div>'
+ ((unreg || []).length
? '<table><thead><tr><th>设备编号</th><th>通道</th><th>厂商</th><th>首次出现</th>'
+ '<th>最后上报</th><th>上报次数</th><th></th></tr></thead><tbody>'
+ unreg.map(x => '<tr>'
+ '<td>' + snCell(x.sensor_id) + '</td>'
+ '<td>' + HB.esc(x.source_channel || '—') + '</td>'
+ '<td>' + HB.esc(x.vendor || '—') + '</td>'
+ '<td>' + HB.dt(x.first_seen_at) + '</td>'
+ '<td>' + HB.ago(x.last_seen_at) + '</td>'
+ '<td>' + (x.report_count || 0) + '</td>'
+ '<td><span class="link" onclick="openAdopt(\'' + HB.esc(x.sensor_id) + '\')">确认入库</span></td>'
+ '</tr>').join('')
+ '</tbody></table>'
: '<div class="empty" style="padding:20px"><span class="ic">🌿</span>没有野设备,台账是干净的</div>')
+ '</div>';
m.innerHTML = html;
}
/* ---------------- 设备入库(出厂录入) ---------------- */
const DEV_TYPE_OPTS = ['DOOR|门磁', 'WATER|水浸', 'VIB|水振', 'CURRENT|电流', 'SOS|紧急按钮', 'SMOKE|烟感'];
const DEV_VENDOR_OPTS = ['OWN|自有(8 位十六进制编号)', 'HORN|豪恩(HORN: 前缀)', 'MIR|麦乐克(MIR: 前缀)'];
function devTypeSelect(id) {
return '<select class="fin" id="' + id + '">'
+ DEV_TYPE_OPTS.map(o => '<option value="' + o.split('|')[0] + '">' + o.split('|')[1] + '</option>').join('')
+ '</select>';
}
function openRegisterDevice() {
showForm('设备入库(出厂录入)',
'<div style="font-size:12px;color:#6b7784;margin:-4px 0 12px;line-height:1.75">'
+ '录进库的设备,家属扫到编号才能绑。<b>没录的绑不上</b>——这是故意的:<br>'
+ '编号只是一串流水号,系统看编号猜不出它是门磁还是水浸,只能由录入的人告诉它。</div>'
+ '<div class="flabel">设备编号 *</div>'
+ '<input class="fin" id="dSn" placeholder="自有:3F7A9C25 豪恩/麦乐克:HORN:xxx / MIR:xxx">'
+ '<div class="flabel">设备类型 *</div>' + devTypeSelect('dType')
+ '<div class="flabel">厂商</div>'
+ '<select class="fin" id="dVendor">'
+ DEV_VENDOR_OPTS.map(o => '<option value="' + o.split('|')[0] + '">' + o.split('|')[1] + '</option>').join('')
+ '</select>'
+ '<div class="flabel">备注(批次 / 供应商)</div>'
+ '<input class="fin" id="dRemark" placeholder="选填">'
+ '<button class="fbtn" onclick="submitRegisterDevice()">入库</button>');
}
async function submitRegisterDevice() {
const sensorId = document.getElementById('dSn').value.trim().toUpperCase();
if (!sensorId) return HB.toast('设备编号要填', 'err');
try {
const r = await HB.post('/api/v1/admin/devices/register', {
sensorId,
typeCode: document.getElementById('dType').value,
vendor: document.getElementById('dVendor').value,
model: null,
remark: document.getElementById('dRemark').value.trim() || null,
});
closeModal();
HB.toast('已入库:' + r.typeName + ' · 等家属来绑');
render();
} catch (e) { HB.toast(e.message, 'err'); }
}
function openAdopt(sn) {
showForm('野设备转正 · ' + sn,
'<div style="font-size:12px;color:#6b7784;margin:-4px 0 12px;line-height:1.75">'
+ '这台机器已经通电上报过,但库里没有它的档案。<br>'
+ '补一个类型就让它入库,之后业主就能绑了。</div>'
+ '<div class="flabel">设备类型 *</div>' + devTypeSelect('aType')
+ '<div class="flabel">备注</div>'
+ '<input class="fin" id="aRemark" value="野设备转正">'
+ '<button class="fbtn" onclick="submitAdopt(\'' + HB.esc(sn) + '\')">确认入库</button>');
}
async function submitAdopt(sn) {
try {
const vendor = sn.startsWith('HORN:') ? 'HORN' : sn.startsWith('MIR:') ? 'MIR' : 'OWN';
const r = await HB.post('/api/v1/admin/devices/unregistered/adopt', {
sensorId: sn,
typeCode: document.getElementById('aType').value,
vendor,
remark: document.getElementById('aRemark').value.trim() || null,
});
closeModal();
HB.toast('已入库:' + r.typeName + ' · 等家属来绑');
render();
} catch (e) { HB.toast(e.message, 'err'); }
}
const onlineBadge = (s) => s === 'ONLINE'
? '<span class="badge b-ok"><span class="dot on"></span>在线</span>'
: s === 'OFFLINE' ? '<span class="badge b-gray"><span class="dot off"></span>离线</span>'
: '<span class="badge b-s2"><span class="dot unk"></span>未知</span>';
async function openDevice(devId) {
const d = STATE.devices.find(x => x.dev_id === devId);
const ev = await HB.get('/api/v1/devices/' + devId + '/events?size=10').catch(() => ({ list: [] }));
const rows = ev.list || ev || [];
document.getElementById('mTitle').textContent = d ? (d.display_name || HB.typeInfo(d.type_code).name) : devId;
document.getElementById('mBody').innerHTML =
'<div class="kv"><div class="k">设备编号(SN)</div><div class="v">'
+ snCell(d && d.sensor_id) + '</div></div>'
+ kv('系统 ID', devId) + kv('类型', d && HB.typeInfo(d.type_code).name)
+ kv('厂家', d && d.vendor) + kv('型号', d && d.model) + kv('户', d && d.household_id)
+ kv('安装位置', d && (d.install_location || '—')) + kv('防区', d && (d.zone_no || '—'))
+ kv('标识依据', d && d.id_basis) + kv('状态', d && onlineBadge(d.online_status))
+ '<div style="margin-top:14px;font-weight:600;font-size:13.5px">最近事件</div>'
+ (rows.length ? rows.map(e => '<div class="kv"><div class="k">' + HB.dt(e.event_time) + '</div>'
+ '<div class="v">' + HB.esc(HB.evName(e.event_type))
+ (e.is_backfill ? ' <span class="badge b-gray">补传</span>' : '')
+ ' <span class="mono" style="color:#98a4b0">seq ' + HB.esc(e.seq || '-') + '</span></div></div>').join('')
: '<div class="empty" style="padding:16px">暂无事件</div>')
+ '<div class="acts"><button class="pri" onclick="sendCmd(\'' + devId + '\',\'REBOOT\')">重启</button>'
+ '<button onclick="sendCmd(\'' + devId + '\',\'DIAG_DUMP\')">诊断上报</button>'
+ '<button onclick="sendCmd(\'' + devId + '\',\'TIME_SYNC\')">校时</button></div>'
+ '<div id="cmdState" style="font-size:12.5px;line-height:1.6;margin-top:10px;min-height:18px"></div>';
document.getElementById('modal').classList.add('show');
}
async function sendCmd(devId, type) {
const box = () => document.getElementById('cmdState');
if (box()) box().innerHTML = '<span style="color:#6b7784">正在提交…</span>';
try {
const r = await HB.post('/api/v1/devices/' + devId + '/commands', { cmdType: type, params: {} });
// 后台和家属端用同一份状态文案(HB.CMD_TEXT)。这里必须回显:
// 命令是写库等接入端领走(I-18),只弹一句"已入队"没法判断到底成没成。
HB.watchCommand(devId, r && (r.commandNo || r.command_no), 'cmdState');
} catch (e) {
if (box()) box().innerHTML = '<span style="color:#c0392b">' + HB.esc(e.message) + '</span>';
}
}
/* ---------------- 住户 ---------------- */
async function renderHomes(m) {
const list = await HB.get('/api/v1/admin/households');
const arr = list || [];
document.getElementById('pgSub').textContent = '共 ' + arr.length + ' 户';
m.innerHTML = '<div class="panel"><div class="panel-h"><h3>住户台账</h3><div class="tools">'
+ '<button class="btn btn-line btn-sm" onclick="openCreateCommunity()">+ 新增小区</button>'
+ '<button class="btn btn-line btn-sm" onclick="openCreateHousehold()">+ 新增住户</button>'
+ '</div></div>'
+ (arr.length ? '<table><thead><tr><th>户 ID</th><th>小区</th><th>楼栋/单元</th><th>房号</th>'
+ '<th>老人</th><th>设备</th><th>SKU</th><th>状态</th></tr></thead><tbody>'
+ arr.map(h => '<tr><td class="mono">' + h.id + '</td>'
+ '<td>' + HB.esc(h.community_name || '—') + '</td>'
+ '<td>' + HB.esc([h.building_name, h.unit_name].filter(Boolean).join(' ') || '—') + '</td>'
+ '<td><b>' + HB.esc(h.room_no || '—') + '</b></td>'
+ '<td>' + (h.elder_count || 0) + '</td><td>' + (h.device_count || 0) + '</td>'
+ '<td>' + HB.esc(h.sku || '—') + '</td>'
+ '<td><span class="badge ' + (h.status === 'ACTIVE' ? 'b-ok' : 'b-gray') + '">' + HB.esc(h.status || '—') + '</span></td>'
+ '</tr>').join('') + '</tbody></table>'
: '<div class="empty"><span class="ic">🏘</span>还没有住户</div>')
+ '</div>';
}
/* ---------------- 账号 ---------------- */
async function renderUsers(m) {
const [users, roles] = await Promise.all([
HB.get('/api/v1/admin/users').catch(() => []),
HB.get('/api/v1/admin/roles').catch(() => []),
]);
const arr = users || [];
document.getElementById('pgSub').textContent = '共 ' + arr.length + ' 个后台账号 · ' + (roles || []).length + ' 个角色';
m.innerHTML = '<div class="panel"><div class="panel-h"><h3>后台账号</h3><div class="tools">'
+ '<button class="btn btn-line btn-sm" onclick="openCreateUser()">+ 新增账号</button>'
+ '<span style="font-size:12px;color:#6b7784">小程序家属不走这张表(wx_user 自助注册)</span>'
+ '</div></div>'
+ (arr.length ? '<table><thead><tr><th>账号</th><th>姓名</th><th>角色</th><th>状态</th><th>最近登录</th><th>创建时间</th><th>数据范围</th></tr></thead><tbody>'
+ arr.map(u => '<tr><td><b>' + HB.esc(u.username) + '</b></td>'
+ '<td>' + HB.esc(u.real_name || '—') + '</td>'
+ '<td>' + (u.roles ? '<span class="badge b-blue">' + HB.esc(u.roles) + '</span>' : '—') + '</td>'
+ '<td><span class="badge ' + (u.status === 'ACTIVE' ? 'b-ok' : 'b-gray') + '">' + HB.esc(u.status) + '</span></td>'
+ '<td>' + HB.ago(u.last_login_at) + '</td><td>' + HB.dt(u.created_at) + '</td>'
+ '<td><span class="link" onclick="openScopes(' + u.id + ',\'' + HB.esc(u.username) + '\')">配置</span></td></tr>').join('')
+ '</tbody></table>'
: '<div class="empty"><span class="ic">👤</span>没有账号</div>')
+ '</div>'
+ '<div class="panel"><div class="panel-h"><h3>角色</h3></div>'
+ ((roles || []).length ? '<table><thead><tr><th>编码</th><th>名称</th><th>说明</th></tr></thead><tbody>'
+ roles.map(r => '<tr><td class="mono">' + HB.esc(r.role_code) + '</td><td>' + HB.esc(r.role_name)
+ '</td><td>' + HB.esc(r.remark || '—') + '</td></tr>').join('') + '</tbody></table>'
: '<div class="empty" style="padding:20px">没有角色</div>')
+ '</div>';
}
/* ---------------- 数据范围(按区域) ----------------
一行一个覆盖范围,由宽到窄:只填省 = 全省;填到区 = 那个区;再选小区/楼栋就细到那里。
省市区的选项由后端从库里已有小区反推(不引国标码表),所以选出来的值必然和
community.province/city/district 字面一致,不会出现"选了广东省、库里写广东"匹配不上的情况。
一行都不填 = 不限制(看全部)。 */
let SCOPE = { opts: null, rows: [], uid: 0, name: '' };
const SCOPE_BLANK = () => ({ province: '', city: '', district: '', communityId: null, buildingId: null, remark: '' });
async function openScopes(uid, username) {
try {
const [opts, rows] = await Promise.all([
SCOPE.opts ? Promise.resolve(SCOPE.opts) : HB.get('/api/v1/admin/scope/options'),
HB.get('/api/v1/admin/users/' + uid + '/scopes'),
]);
SCOPE.opts = opts;
SCOPE.uid = uid;
SCOPE.name = username;
SCOPE.rows = (rows || []).map(r => ({
province: r.province || '', city: r.city || '', district: r.district || '',
communityId: r.community_id == null ? null : Number(r.community_id),
buildingId: r.building_id == null ? null : Number(r.building_id),
remark: r.remark || '',
}));
if (!SCOPE.rows.length) SCOPE.rows.push(SCOPE_BLANK());
drawScope();
} catch (e) { HB.toast(e.message, 'err'); }
}
function scopeSel(list, cur, emptyLabel, i, f) {
return '<select data-i="' + i + '" data-f="' + f + '" onchange="onScopeChange(this)">'
+ '<option value="">' + emptyLabel + '</option>'
+ list.map(v => '<option value="' + HB.esc(v.v) + '"' + (String(cur) === String(v.v) ? ' selected' : '') + '>'
+ HB.esc(v.t) + '</option>').join('')
+ '</select>';
}
function drawScope() {
const o = SCOPE.opts || {};
const regions = o.regions || [];
const comms = o.communities || [];
const blds = o.buildings || [];
const body = SCOPE.rows.map((r, i) => {
const prov = regions.find(x => x.province === r.province) || { cities: [] };
const cities = prov.cities || [];
const cityObj = cities.find(c => (c.city || '') === (r.city || '')) || { districts: [] };
const districts = cityObj.districts || [];
// 小区按已选的省市区过滤;楼栋按已选的小区过滤
const rowComms = comms.filter(c =>
(!r.province || c.province === r.province)
&& (!r.city || c.city === r.city)
&& (!r.district || c.district === r.district));
const rowBlds = blds.filter(b => String(b.community_id) === String(r.communityId));
return '<tr>'
+ '<td>' + scopeSel(regions.map(x => ({ v: x.province, t: x.province })), r.province, '省·不限', i, 'province') + '</td>'
+ '<td>' + scopeSel(cities.map(c => ({ v: c.city, t: c.city || '—' })), r.city, '市·不限', i, 'city') + '</td>'
+ '<td>' + scopeSel(districts.map(d => ({ v: d, t: d || '—' })), r.district, '区·不限', i, 'district') + '</td>'
+ '<td>' + scopeSel(rowComms.map(c => ({ v: c.id, t: c.name })), r.communityId, '小区·不限', i, 'communityId') + '</td>'
+ '<td>' + scopeSel(rowBlds.map(b => ({ v: b.id, t: b.name })), r.buildingId, '楼栋·不限', i, 'buildingId') + '</td>'
+ '<td><input data-i="' + i + '" value="' + HB.esc(r.remark) + '" placeholder="备注" oninput="onScopeRemark(this)"></td>'
+ '<td><button class="del" onclick="delScopeRow(' + i + ')">删</button></td>'
+ '</tr>';
}).join('');
document.getElementById('mTitle').textContent = '数据范围 · ' + SCOPE.name;
document.getElementById('mBody').innerHTML =
'<div class="tip-bar">一行一个覆盖范围:<b>只填省=全省</b>;填到区=那个区;再选小区/楼栋就细到那里。'
+ '一行都不填=<b>看全部</b>(运营通常这样)。保存后立即生效,不用重新登录。</div>'
+ '<table class="scope-tb"><thead><tr><th>省</th><th>市</th><th>区</th><th>小区</th><th>楼栋</th><th>备注</th><th></th></tr></thead>'
+ '<tbody>' + body + '</tbody></table>'
+ '<div class="acts" style="position:static;box-shadow:none;padding-top:14px">'
+ '<button onclick="addScopeRow()">+ 加一行</button>'
+ '<button class="pri" onclick="saveScopes()">保存</button></div>';
document.getElementById('modal').classList.add('show');
}
function onScopeChange(el) {
const i = Number(el.dataset.i);
const f = el.dataset.f;
const r = SCOPE.rows[i];
if (!r) return;
const v = el.value;
if (f === 'province') { r.province = v; r.city = ''; r.district = ''; r.communityId = null; r.buildingId = null; }
else if (f === 'city') { r.city = v; r.district = ''; r.communityId = null; r.buildingId = null; }
else if (f === 'district') { r.district = v; r.communityId = null; r.buildingId = null; }
else if (f === 'communityId') {
r.communityId = v ? Number(v) : null;
r.buildingId = null;
// 选了小区但省市区还是空的,顺手补上——否则这一行只按"省市区全不限 + 小区"匹配,
// 看着能存,实际是把范围放宽了。补上更好读,也便于以后排查。
const c = (SCOPE.opts.communities || []).find(x => String(x.id) === String(v));
if (c) { r.province = c.province || r.province; r.city = c.city || r.city; r.district = c.district || r.district; }
}
else if (f === 'buildingId') { r.buildingId = v ? Number(v) : null; }
drawScope();
}
function onScopeRemark(el) {
const r = SCOPE.rows[Number(el.dataset.i)];
if (r) r.remark = el.value; // 不重绘,否则输入框会丢焦点
}
function addScopeRow() { SCOPE.rows.push(SCOPE_BLANK()); drawScope(); }
function delScopeRow(i) {
SCOPE.rows.splice(i, 1);
if (!SCOPE.rows.length) SCOPE.rows.push(SCOPE_BLANK());
drawScope();
}
async function saveScopes() {
const rows = SCOPE.rows.filter(r => r.province || r.city || r.district || r.communityId || r.buildingId);
try {
const d = await HB.put('/api/v1/admin/users/' + SCOPE.uid + '/scopes', { scopes: rows });
closeModal();
HB.toast(rows.length ? ('已保存 ' + rows.length + ' 行范围') : '已清空(该账号改为不限范围)');
render();
} catch (e) { HB.toast(e.message, 'err'); }
}
/* ---------------- 字典 ---------------- */
async function renderDicts(m) {
const ds = await HB.get('/api/v1/admin/dicts') || [];
let html = '<div class="panel"><div class="panel-h"><h3>字典</h3>'
+ '<span style="font-size:12px;color:#6b7784">设备类型、告警级别、状态这些枚举的唯一来源</span></div>';
if (!ds.length) html += '<div class="empty"><span class="ic">📖</span>没有字典</div>';
else {
html += '<table><thead><tr><th>类型</th><th>名称</th><th>项数</th><th></th></tr></thead><tbody>';
for (const d of ds) {
const items = await HB.get('/api/v1/admin/dicts/' + encodeURIComponent(d.dict_type)).catch(() => []);
DICT_CACHE[d.dict_type] = items || [];
html += '<tr><td class="mono">' + HB.esc(d.dict_type) + '</td><td>' + HB.esc(d.dict_name) + '</td>'
+ '<td>' + (items ? items.length : 0) + '</td>'
+ '<td><span class="link" onclick="showDict(\'' + HB.esc(d.dict_type) + '\')">查看</span></td></tr>';
}
html += '</tbody></table>';
}
m.innerHTML = html + '</div>';
}
let DICT_CACHE = [];
function showDict(key) {
const items = DICT_CACHE[key] || [];
document.getElementById('mTitle').textContent = '字典项 · ' + key;
document.getElementById('mBody').innerHTML = items.length
? items.map(i => kv(i.item_value, i.item_label)).join('')
: '<div class="empty">没有项</div>';
document.getElementById('modal').classList.add('show');
}
/* ---------------- 工单 ---------------- */
async function renderOrders(m) {
const r = await HB.get('/api/v1/admin/work-orders?size=100').catch(() => ({ list: [] }));
const list = r.list || r || [];
document.getElementById('pgSub').textContent = '共 ' + (r.total || list.length) + ' 条';
m.innerHTML = '<div class="panel"><div class="panel-h"><h3>工单</h3>'
+ '<span style="font-size:12px;color:#6b7784">物业上门、设备维修走这里</span></div>'
+ (list.length ? '<table><thead><tr><th>单号</th><th>类型</th><th>标题</th><th>户</th><th>状态</th><th>创建</th></tr></thead><tbody>'
+ list.map(o => '<tr><td class="mono">' + HB.esc(o.order_no || o.id) + '</td>'
+ '<td>' + HB.esc(o.order_type || o.type || '—') + '</td>'
+ '<td>' + HB.esc(o.title || '—') + '</td><td>' + (o.household_id || '—') + '</td>'
+ '<td><span class="badge b-gray">' + HB.esc(o.status || '—') + '</span></td>'
+ '<td>' + HB.dt(o.created_at) + '</td></tr>').join('') + '</tbody></table>'
: '<div class="empty"><span class="ic">🧾</span>还没有工单</div>')
+ '</div>';
}
/* ---------------- 审计 ---------------- */
async function renderAudit(m) {
const r = await HB.get('/api/v1/admin/audit-logs?size=200').catch(() => ({ list: [] }));
const list = r.list || r || [];
document.getElementById('pgSub').textContent = '共 ' + (r.total || list.length) + ' 条(写操作全部留痕)';
m.innerHTML = '<div class="panel"><div class="panel-h"><h3>操作日志</h3>'
+ '<span style="font-size:12px;color:#6b7784">登录、绑定、告警流转、命令下发</span></div>'
+ (list.length ? '<table><thead><tr><th>时间</th><th>操作人</th><th>动作</th><th>对象</th><th>IP</th></tr></thead><tbody>'
+ list.map(a => '<tr><td style="white-space:nowrap">' + HB.dt(a.created_at || a.op_time) + '</td>'
+ '<td>' + HB.esc(a.operator_name || a.operator_id || '—') + '</td>'
+ '<td><span class="badge b-blue">' + HB.esc(a.action || a.action_code || '—') + '</span></td>'
+ '<td>' + HB.esc((a.target_type || '') + ' ' + (a.target_id || '')) + '</td>'
+ '<td class="mono">' + HB.esc(a.ip || '—') + '</td></tr>').join('') + '</tbody></table>'
: '<div class="empty"><span class="ic">📋</span>还没有日志</div>')
+ '</div>';
}
/* ---------------- 通用 ---------------- */
function kv(k, v) {
return '<div class="kv"><div class="k">' + k + '</div><div class="v">' + HB.esc(v == null ? '—' : v) + '</div></div>';
}
/* ---------------- 设备编号(SN)格子 ----------------
后台"设备"列下面挂的是 dev_id(系统生成的 D1794213...),那是我们自己看的,
客户手上、机身贴纸上、报修时报的都是 sensor_id(HORN:999111 这种)。
所以单开一列,并且点一下就复制——对单的时候手抄 13 位十六进制必错。 */
function snCell(sn) {
if (!sn) return '<span class="mono">—</span>';
return '<span class="sn" title="点一下复制" data-sn="' + HB.esc(sn) + '" '
+ 'onclick="copySn(this)">' + HB.esc(sn) + '</span>';
}
/* 告警里的"哪台设备":别名 + 机身编号。以前只给 dev_id(T-G-03 / D1794…),
客户报编号时对不上,运营也不知道是哪台。2026-11-09 */
function devCell(a) {
const nm = a.device_name || '';
const sn = a.device_sn || '';
if (!nm && !sn) return '<span class="mono">—</span>';
return (nm ? '<b>' + HB.esc(nm) + '</b>' : '')
+ (sn ? '<div class="mono">' + HB.esc(sn) + '</div>' : '');
}
function copySn(el) {
const sn = el.getAttribute('data-sn') || '';
// 原始文字存一份,防止连点两下时把"已复制 ✓"当成原文还原回去
if (!el.dataset.label) el.dataset.label = el.textContent;
const flash = () => {
el.textContent = '已复制 ✓';
el.classList.add('ok');
clearTimeout(el._t);
el._t = setTimeout(() => { el.textContent = el.dataset.label; el.classList.remove('ok'); }, 900);
HB.toast('已复制 ' + sn);
};
// 127.0.0.1 / https 走 Clipboard API;局域网 IP 访问(非安全上下文)走老办法兜底
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(sn).then(flash).catch(() => fallbackCopy(sn, flash));
} else {
fallbackCopy(sn, flash);
}
}
function fallbackCopy(text, done) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); done(); } catch (e) { HB.toast('复制失败,请手动选中', 'err'); }
document.body.removeChild(ta);
}
function showForm(title, html) {
document.getElementById('mTitle').textContent = title;
document.getElementById('mBody').innerHTML = html;
document.getElementById('modal').classList.add('show');
}
/* ---------------- 新增:小区 ---------------- */
function openCreateCommunity() {
showForm('新增小区',
'<div class="flabel">小区名称 *</div><input class="fin" id="cName" placeholder="如:幸福里">'
+ '<div class="frow"><input class="fin" id="cProv" placeholder="省" value="广东省">'
+ '<input class="fin" id="cCity" placeholder="市" value="中山市">'
+ '<input class="fin" id="cDist" placeholder="区" value="东区"></div>'
+ '<div class="flabel">详细地址</div><input class="fin" id="cAddr" placeholder="街道门牌">'
+ '<div class="frow"><input class="fin" id="cMgr" placeholder="负责人">'
+ '<input class="fin" id="cPhone" placeholder="负责人手机号"></div>'
+ '<div class="flabel">小区编码(留空自动生成)</div>'
+ '<input class="fin" id="cCode" placeholder="C-2026-001">'
+ '<button class="fbtn" onclick="submitCommunity()">保存</button>');
}
async function submitCommunity() {
const name = document.getElementById('cName').value.trim();
if (!name) return HB.toast('小区名称要填', 'err');
const code = document.getElementById('cCode').value.trim()
|| 'C' + Date.now().toString(36).toUpperCase();
try {
await HB.post('/api/v1/admin/communities', {
code, name,
province: document.getElementById('cProv').value.trim(),
city: document.getElementById('cCity').value.trim(),
district: document.getElementById('cDist').value.trim(),
address: document.getElementById('cAddr').value.trim(),
managerName: document.getElementById('cMgr').value.trim() || null,
managerPhone: document.getElementById('cPhone').value.trim() || null,
});
closeModal(); HB.toast('小区已新建'); render();
} catch (e) { HB.toast(e.message, 'err'); }
}
/* ---------------- 新增:后台账号 ---------------- */
function openCreateUser() {
showForm('新增后台账号',
'<div class="flabel">账号 *</div><input class="fin" id="uName" placeholder="登录名">'
+ '<div class="flabel">密码 *</div><input class="fin" id="uPwd" type="password" placeholder="至少 6 位">'
+ '<div class="frow"><input class="fin" id="uReal" placeholder="姓名">'
+ '<input class="fin" id="uMobile" placeholder="手机号"></div>'
+ '<div class="flabel">角色</div>'
+ '<select class="fin" id="uRole" onchange="roleHint()">'
+ ['OPERATOR|运营(看全部)', 'PROPERTY|物业(只看配了范围的小区)']
.map(o => '<option value="' + o.split('|')[0] + '">' + o.split('|')[1] + '</option>').join('')
+ '</select>'
+ '<div id="roleHint" style="font-size:11.5px;color:#8a949e;margin:-4px 0 10px">'
+ '运营不配数据范围,默认全放;物业要在 sys_user_scope 里配小区。</div>'
+ '<button class="fbtn" onclick="submitUser()">创建账号</button>');
}
function roleHint() {
const r = document.getElementById('uRole').value;
document.getElementById('roleHint').textContent = r === 'PROPERTY'
? '物业账号没配 sys_user_scope 时会按"全放"处理——上线前记得配小区。'
: '运营不配数据范围,默认看全部小区。';
}
async function submitUser() {
const username = document.getElementById('uName').value.trim();
const password = document.getElementById('uPwd').value;
if (!username || !password) return HB.toast('账号和密码都要填', 'err');
if (password.length < 6) return HB.toast('密码至少 6 位', 'err');
try {
await HB.post('/api/v1/admin/users', {
username, password,
realName: document.getElementById('uReal').value.trim() || username,
mobile: document.getElementById('uMobile').value.trim() || null,
roleCode: document.getElementById('uRole').value,
});
closeModal(); HB.toast('账号已创建'); render();
} catch (e) { HB.toast(e.message, 'err'); }
}
/* ---------------- 新增:住户 ---------------- */
async function openCreateHousehold() {
let cs = [];
try { cs = await HB.get('/api/v1/admin/communities') || []; } catch (e) { }
if (!cs.length) return HB.toast('先建一个小区', 'err');
showForm('新增住户',
'<div class="flabel">小区 *</div><select class="fin" id="hComm" onchange="loadBuildings()">'
+ cs.map(c => '<option value="' + c.id + '">' + HB.esc(c.name) + '</option>').join('')
+ '</select>'
+ '<div class="frow"><select class="fin" id="hBuild" onchange="loadUnits()"></select>'
+ '<select class="fin" id="hUnit"></select></div>'
+ '<div class="frow"><input class="fin" id="hRoom" placeholder="房号 * 如 1-1-101">'
+ '<input class="fin" id="hFloor" placeholder="楼层" inputmode="numeric"></div>'
+ '<div class="flabel">备注</div><input class="fin" id="hRemark" placeholder="选填">'
+ '<div style="font-size:11.5px;color:#8a949e;margin:-2px 0 10px">'
+ '楼栋 / 单元为空时先去"住户管理"补建,或者用家属端扫码建家。</div>'
+ '<button class="fbtn" onclick="submitHousehold()">保存</button>');
loadBuildings();
}
async function loadBuildings() {
const cid = document.getElementById('hComm').value;
const sel = document.getElementById('hBuild');
sel.innerHTML = '<option value="">加载中…</option>';
try {
const list = await HB.get('/api/v1/admin/buildings?communityId=' + cid) || [];
sel.innerHTML = list.length
? list.map(b => '<option value="' + b.id + '">' + HB.esc(b.name) + '</option>').join('')
: '<option value="">(该小区还没有楼栋)</option>';
loadUnits();
} catch (e) { sel.innerHTML = '<option value="">加载失败</option>'; }
}
async function loadUnits() {
const bid = document.getElementById('hBuild').value;
const sel = document.getElementById('hUnit');
if (!bid) { sel.innerHTML = '<option value="">(无单元)</option>'; return; }
try {
const list = await HB.get('/api/v1/admin/units?buildingId=' + bid) || [];
sel.innerHTML = list.length
? list.map(u => '<option value="' + u.id + '">' + HB.esc(u.name) + '</option>').join('')
: '<option value="">(该楼栋还没有单元)</option>';
} catch (e) { sel.innerHTML = '<option value="">加载失败</option>'; }
}
async function submitHousehold() {
const cid = document.getElementById('hComm').value;
const bid = document.getElementById('hBuild').value;
const uid = document.getElementById('hUnit').value;
const room = document.getElementById('hRoom').value.trim();
if (!bid || !uid) return HB.toast('这个小区还没有楼栋/单元,先补建', 'err');
if (!room) return HB.toast('房号要填', 'err');
const floor = parseInt(document.getElementById('hFloor').value, 10);
try {
await HB.post('/api/v1/admin/households', {
communityId: Number(cid), buildingId: Number(bid), unitId: Number(uid),
roomNo: room, floor: isFinite(floor) ? floor : null,
remark: document.getElementById('hRemark').value.trim() || null,
});
closeModal(); HB.toast('住户已新建'); render();
} catch (e) { HB.toast(e.message, 'err'); }
}
function closeModal() { document.getElementById('modal').classList.remove('show'); }
</script>
</body>
</html>