按周工交付的 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),不做归一化以免整树摘要失效。
1111 lines
60 KiB
HTML
1111 lines
60 KiB
HTML
<!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>
|