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

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

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

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

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

1764 lines
92 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,maximum-scale=1,viewport-fit=cover">
<title>鹤仆云 · 家属端(小程序 H5 演示)</title>
<style>
:root{
--ink:#1b2430; --sub:#6b7784; --line:#e8edf2; --soft:#f5f8fa;
--brand:#146b7a; --brand2:#1d8fa3; --red:#d94b4b; --amber:#dd8a24; --green:#2f9e68;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{height:100%}
body{
margin:0;background:#eef2f5;color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
font-size:15px;line-height:1.6;
display:flex;align-items:center;justify-content:center;
}
#phone{
width:100%;max-width:430px;height:100%;max-height:900px;background:#fff;
display:flex;flex-direction:column;position:relative;overflow:hidden;
box-shadow:0 18px 60px rgba(20,40,60,.16);
}
@media(min-width:520px){ #phone{border-radius:26px} }
/* ---------- 登录 ---------- */
#login{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;
padding:0 30px;background:linear-gradient(165deg,#146b7a 0%,#1d8fa3 55%,#37a8b8 100%);color:#fff;z-index:20}
#login.hide{display:none}
.logo{width:64px;height:64px;border-radius:20px;background:rgba(255,255,255,.16);
display:flex;align-items:center;justify-content:center;font-size:32px;margin-bottom:22px}
#login h1{font-size:26px;margin:0 0 6px;font-weight:600;letter-spacing:.5px}
#login p.sub{margin:0 0 34px;font-size:13.5px;opacity:.82}
.field{background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.24);border-radius:12px;
padding:12px 14px;margin-bottom:12px;display:flex;align-items:center;gap:10px}
.field input{flex:1;background:none;border:0;outline:none;color:#fff;font-size:15px}
.field input::placeholder{color:rgba(255,255,255,.62)}
.btn{width:100%;border:0;border-radius:12px;padding:13px;font-size:15.5px;font-weight:600;
cursor:pointer;transition:.15s}
.btn:active{transform:scale(.985)}
.btn-main{background:#fff;color:var(--brand)}
.btn-ghost{background:rgba(255,255,255,.14);color:#fff;margin-top:10px;border:1px solid rgba(255,255,255,.3)}
.btn-mini{background:rgba(255,255,255,.2);color:#fff;border:0;border-radius:8px;padding:7px 11px;font-size:12.5px;cursor:pointer;white-space:nowrap}
.login-foot{margin-top:26px;font-size:12px;opacity:.72;text-align:center;line-height:1.8}
/* ---------- 主框架 ---------- */
#app{display:none;flex-direction:column;height:100%}
#app.show{display:flex}
header{background:linear-gradient(160deg,#146b7a,#1d8fa3);color:#fff;padding:18px 18px 20px;
display:flex;align-items:flex-start;justify-content:space-between;flex:0 0 auto}
header .hh{font-size:12.5px;opacity:.8;margin-bottom:3px}
header .tt{font-size:19px;font-weight:600;letter-spacing:.3px}
header .icon-btn{background:rgba(255,255,255,.16);border:0;color:#fff;width:34px;height:34px;
border-radius:10px;font-size:15px;cursor:pointer}
main{flex:1;overflow-y:auto;overflow-x:hidden;padding:14px 14px 18px;background:#f5f8fa;-webkit-overflow-scrolling:touch}
nav{flex:0 0 auto;display:flex;border-top:1px solid var(--line);background:#fff;padding:6px 0 8px}
nav button{flex:1;background:none;border:0;display:flex;flex-direction:column;align-items:center;
gap:2px;font-size:11px;color:#9aa6b2;cursor:pointer;padding:4px 0;position:relative}
nav button .ic{font-size:19px;line-height:1}
nav button.on{color:var(--brand);font-weight:600}
nav .dot{position:absolute;top:1px;right:50%;margin-right:-16px;background:var(--red);color:#fff;
font-size:9px;line-height:1;padding:2px 4px;border-radius:8px;font-weight:600}
/* ---------- 实时告警横幅 ---------- */
#rtBanner{position:absolute;left:0;right:0;top:0;z-index:60;transform:translateY(-105%);
transition:transform .28s cubic-bezier(.2,.9,.3,1);pointer-events:none}
#rtBanner.on{transform:translateY(0);pointer-events:auto}
.rtb{background:linear-gradient(135deg,#c62828,#e53935);color:#fff;padding:11px 13px 12px;
box-shadow:0 6px 18px rgba(198,40,40,.35)}
.rtb.s2{background:linear-gradient(135deg,#b26a00,#e08e00);box-shadow:0 6px 18px rgba(178,106,0,.3)}
.rtb-h{display:flex;align-items:center;gap:7px;font-size:11.5px;opacity:.92}
.rtb-h .lv{background:rgba(255,255,255,.22);border-radius:5px;padding:1px 6px;font-weight:700;font-size:10.5px}
.rtb-h .tm{margin-left:auto;opacity:.8}
.rtb-t{font-size:15.5px;font-weight:700;margin:4px 0 2px;display:flex;align-items:center;gap:6px}
.rtb-c{font-size:12px;opacity:.92;line-height:1.5}
.rtb-a{display:flex;gap:8px;margin-top:10px}
.rtb-a button{flex:1;border:0;border-radius:8px;padding:8px;font-size:13px;font-weight:600;cursor:pointer}
.rtb-a .p{background:#fff;color:#b71c1c}
.rtb-a .s{background:rgba(255,255,255,.2);color:#fff}
.rtb.pulse{animation:rtbPulse 1.1s ease-in-out 3}
@keyframes rtbPulse{0%,100%{box-shadow:0 6px 18px rgba(198,40,40,.35)}
50%{box-shadow:0 6px 30px rgba(198,40,40,.85)}}
/* ---------- 告警链(同一事件多次触发) ---------- */
.chain{background:#f7fafb;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin:12px 0}
.chain-h{font-size:12px;color:#5c6b76;margin-bottom:9px;line-height:1.5}
.chain-h b{color:var(--brand);font-size:15px;margin:0 2px}
.chain-h .r{float:right;font-family:Consolas,monospace;font-size:11.5px;color:#7b8894}
/* 列表限高 + 内部滚动:一组触发几十次时不把下面的按钮顶出屏幕。
4 条可见(每条约 34px),再多就在框里滑,外层页面不用动。 */
.chain-l{display:flex;flex-direction:column;gap:6px;max-height:136px;overflow-y:auto;
-webkit-overflow-scrolling:touch;overscroll-behavior:contain;padding-right:3px;
position:relative}
.chain-l::-webkit-scrollbar{width:4px}
.chain-l::-webkit-scrollbar-track{background:transparent}
.chain-l::-webkit-scrollbar-thumb{background:#cfdae0;border-radius:3px}
/* 条数多时在标题右侧提示"可滑",不占额外高度 */
.chain-more{float:right;font-size:11px;color:#8d9aa5;font-weight:400}
.chain-more::before{content:'↕ '}
.ci{display:flex;align-items:center;gap:8px;font-size:12.5px;color:#6b7784;
padding:5px 8px;border-radius:7px;background:#fff;border:1px solid #eef2f5;min-width:0}
/* 三个 span 都不许换行、不许被压缩:列表限高后宽度变窄,允许 shrink 会把时间压成竖排 */
.ci .ct{font-family:Consolas,Menlo,monospace;color:#334a55;font-size:12px;
white-space:nowrap;flex:0 0 auto}
.ci .cs{color:#93a1ac;white-space:nowrap;flex:0 0 auto}
.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{margin-left:auto;background:#e6f2f4;color:var(--brand);border-radius:6px;
padding:1px 7px;font-size:11px;font-weight:600;white-space:nowrap;flex:0 0 auto}
.sup-tip{background:#fff7ed;border:1px solid #fed7aa;color:#9a3412;border-radius:10px;
padding:10px 12px;font-size:12.5px;line-height:1.6;margin-bottom:12px;
display:flex;align-items:center;gap:10px}
.sup-tip button{margin-left:auto;flex:0 0 auto;background:#c2410c;color:#fff;border:0;
border-radius:8px;padding:7px 11px;font-size:12px;cursor:pointer;white-space:nowrap}
/* ---------- 卡片 ---------- */
.card{background:#fff;border-radius:14px;padding:14px;margin-bottom:12px;
box-shadow:0 1px 2px rgba(18,40,60,.05)}
.card-t{font-size:13px;color:var(--sub);margin:0 0 10px;display:flex;justify-content:space-between;align-items:center}
.card-t b{color:var(--ink);font-size:14px;font-weight:600}
.elder{background:linear-gradient(150deg,#1d8fa3,#22a2a8);color:#fff;border-radius:16px;
padding:16px;margin-bottom:12px;position:relative;overflow:hidden}
.elder::after{content:"";position:absolute;right:-30px;top:-40px;width:130px;height:130px;
border-radius:50%;background:rgba(255,255,255,.08)}
.elder .row{display:flex;align-items:center;gap:12px;position:relative;z-index:1}
.avatar{width:48px;height:48px;border-radius:50%;background:rgba(255,255,255,.2);
display:flex;align-items:center;justify-content:center;font-size:19px;font-weight:600;flex:0 0 auto}
.elder .nm{font-size:17px;font-weight:600}
.elder .meta{font-size:12px;opacity:.85;margin-top:2px}
.elder .state{margin-top:12px;background:rgba(0,0,0,.14);border-radius:9px;padding:8px 11px;
font-size:12.5px;display:flex;align-items:center;gap:7px;position:relative;z-index:1}
/* 首页"最近有活动"的呼吸绿点。
原来这个类叫 .pulse,和下面告警横幅的 .rtb.pulse 撞名——
S1 横幅于是被套上 7px 宽、圆角 50%、浅绿底,整条横幅压成一个小圆点。
2026-11-09 改名拆开,各管各的。 */
.live-dot{width:7px;height:7px;border-radius:50%;background:#8ef0b4;box-shadow:0 0 0 0 rgba(142,240,180,.7);
animation:pulse 1.8s infinite}
@keyframes pulse{70%{box-shadow:0 0 0 7px rgba(142,240,180,0)}100%{box-shadow:0 0 0 0 rgba(142,240,180,0)}}
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin-bottom:12px}
.stat{background:#fff;border-radius:12px;padding:12px 8px;text-align:center;
box-shadow:0 1px 2px rgba(18,40,60,.05)}
.stat .n{font-size:21px;font-weight:700;line-height:1.2}
.stat .l{font-size:11px;color:var(--sub);margin-top:2px}
.stat.warn .n{color:var(--red)}
.stat.ok .n{color:var(--green)}
.quick{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;margin-bottom:12px}
.quick button{background:#fff;border:0;border-radius:12px;padding:11px 4px;font-size:11.5px;
color:var(--ink);cursor:pointer;box-shadow:0 1px 2px rgba(18,40,60,.05);display:flex;
flex-direction:column;align-items:center;gap:5px}
.quick button span{font-size:19px}
/* 设备 */
.dev{display:flex;align-items:center;gap:11px;padding:11px 0;border-bottom:1px solid var(--line);cursor:pointer}
.dev:last-child{border-bottom:0;padding-bottom:0}
.dev:first-child{padding-top:0}
.dev .ic{width:40px;height:40px;border-radius:11px;display:flex;align-items:center;justify-content:center;
font-size:19px;flex:0 0 auto;background:#f0f5f8}
.dev .info{flex:1;min-width:0}
.dev .nm{font-size:14.5px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dev .sub{font-size:11.5px;color:var(--sub);margin-top:1px}
.dev .rt{text-align:right;font-size:11px;color:var(--sub);flex:0 0 auto}
.tag{display:inline-block;font-size:10.5px;padding:1px 7px;border-radius:6px;font-weight:500}
.t-on{background:#e6f6ee;color:#2f9e68}
.t-off{background:#f2f4f6;color:#8a949e}
.t-unk{background:#fdf3e3;color:#b87a1f}
/* 告警 */
.alarm{display:flex;gap:10px;padding:12px;background:#fff;border-radius:12px;margin-bottom:9px;
cursor:pointer;box-shadow:0 1px 2px rgba(18,40,60,.05);position:relative;overflow:hidden}
.alarm .bar{width:3px;border-radius:2px;flex:0 0 auto}
.lv1 .bar{background:var(--red)} .lv2 .bar{background:var(--amber)} .lv3 .bar{background:#4a88c7}
.alarm .bd{flex:1;min-width:0}
.alarm .ti{font-size:14.5px;font-weight:600;display:flex;justify-content:space-between;gap:8px}
.alarm .ti em{font-style:normal;font-size:11px;font-weight:400;color:var(--sub);flex:0 0 auto}
.alarm .ct{font-size:12px;color:var(--sub);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.alarm .mt{font-size:11px;color:#9aa6b2;margin-top:5px;display:flex;gap:10px;align-items:center}
.chip{font-size:10.5px;padding:1px 6px;border-radius:5px;background:#f2f4f6;color:#697887}
.chip.open{background:#fdeaea;color:#c0392b}
.chip.done{background:#e9f5ef;color:#2f9e68}
.chip.multi{background:#fff4e5;color:#b45309}
.chip.quiet{background:#eef0f4;color:#6b7784}
/* 已恢复:绿描边、浅绿底,和"已处置"的实心绿区分开——设备好了不等于有人处理过 */
.chip.rec{background:#f2fbf6;color:#1f8a56;border:1px solid #bfe3cf}
/* ---------- 免打扰设置 ---------- */
.sw-row{display:flex;align-items:center;gap:12px;padding:12px 0;border-bottom:1px solid var(--line)}
.sw-row b{font-size:14px;font-weight:600}
.sw-row .sub{font-size:11.5px;color:#8a949e;margin-top:3px;line-height:1.45}
.sw{margin-left:auto;flex:0 0 auto;position:relative;width:46px;height:27px;cursor:pointer}
.sw input{position:absolute;opacity:0;width:0;height:0}
.sw i{display:block;width:46px;height:27px;border-radius:14px;background:#d6dee4;transition:.18s}
.sw i::after{content:'';position:absolute;top:3px;left:3px;width:21px;height:21px;border-radius:50%;
background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.2);transition:.18s}
.sw input:checked + i{background:var(--brand)}
.sw input:checked + i::after{transform:translateX(19px)}
input[type=time]{border:1px solid #e8edf2;border-radius:8px;padding:7px 9px;font-size:13.5px;
font-family:inherit;color:var(--ink);background:#fff;width:100%;box-sizing:border-box}
input[type=time]:focus{border-color:var(--brand);outline:none}
.tip-s1{background:#fef2f2;border:1px solid #fbd5d5;color:#a83232;border-radius:9px;
padding:9px 11px;font-size:12px;line-height:1.6;margin:12px 0 6px}
.tip-s1 b{color:#c0392b}
.seg{display:flex;background:#e9eef2;border-radius:10px;padding:3px;margin-bottom:12px}
.seg button{flex:1;border:0;background:none;padding:7px;border-radius:8px;font-size:13px;
color:var(--sub);cursor:pointer;font-weight:500}
.seg button.on{background:#fff;color:var(--brand);box-shadow:0 1px 3px rgba(0,0,0,.08)}
.empty{text-align:center;padding:46px 20px;color:#9aa6b2;font-size:13px}
.empty .ic{font-size:34px;display:block;margin-bottom:10px;opacity:.5}
/* 弹层 */
#sheet{position:absolute;inset:0;background:rgba(16,26,36,.44);z-index:30;display:none;
align-items:flex-end;backdrop-filter:blur(2px)}
#sheet.show{display:flex}
#sheet .panel{background:#fff;width:100%;border-radius:18px 18px 0 0;max-height:86%;
overflow-y:auto;padding:18px 16px 22px;animation:up .22s ease-out}
@keyframes up{from{transform:translateY(40px);opacity:.5}to{transform:none;opacity:1}}
.sheet-h{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px}
.sheet-h h3{margin:0;font-size:17px}
.sheet-h button{background:#f2f4f6;border:0;width:28px;height:28px;border-radius:9px;cursor:pointer;font-size:14px;color:#6b7784}
.kv{display:flex;padding:9px 0;border-bottom:1px solid var(--line);font-size:13.5px}
.kv:last-child{border-bottom:0}
.kv .k{color:var(--sub);width:104px;flex:0 0 auto}
.kv .v{flex:1;word-break:break-all}
.acts{display:flex;gap:9px;margin:14px 0 0;flex-wrap:wrap}
.acts button{flex:1;min-width:88px;border:1px solid var(--line);background:#fff;border-radius:10px;
padding:11px;font-size:14px;cursor:pointer;font-weight:500;color:var(--ink)}
.acts button.pri{background:var(--brand);color:#fff;border-color:var(--brand)}
.acts button.dan{border-color:#f2c4c4;color:var(--red)}
/* 只有告警详情用吸底:那一页内容会随"历史触发记录"变长,小屏(iPhone SE 375×667)
内容放不下时,按钮仍然贴在面板底部可见,不用先滚到底再找按钮。
白底 + 上方一道淡投影,把按钮和内容分开。其他弹层内容短,不走这套。 */
.acts.sticky{position:sticky;bottom:-6px;background:#fff;z-index:3;padding:10px 0 6px;
margin-bottom:0;box-shadow:0 -10px 14px -10px rgba(16,26,36,.22)}
/* 矮屏收紧:把纵向空间让给按钮,列表少露一条 */
@media (max-height:700px){
.kv{padding:7px 0}
.chain-l{max-height:104px}
}
.tl{position:relative;padding-left:18px;margin-top:6px}
.tl::before{content:"";position:absolute;left:4px;top:4px;bottom:4px;width:2px;background:var(--line)}
.tl .it{position:relative;padding-bottom:14px;font-size:12.5px}
.tl .it::before{content:"";position:absolute;left:-17px;top:5px;width:7px;height:7px;border-radius:50%;
background:#c8d3dc;border:2px solid #fff}
.tl .it.f::before{background:var(--brand)}
.tl .it .w{color:var(--sub);font-size:11.5px}
.tl .it .d{margin-top:1px}
#hb-toast{position:fixed;left:50%;bottom:88px;transform:translate(-50%,14px);background:rgba(28,38,48,.93);
color:#fff;padding:9px 17px;border-radius:10px;font-size:13.5px;opacity:0;pointer-events:none;
transition:.22s;z-index:99;max-width:80%;text-align:center}
#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:8px}
@keyframes sk{0%{background-position:100% 50%}100%{background-position:0 50%}}
/* ---- 建档 / 绑定表单 ---- */
.chInp{width:100%;border:1px solid var(--line);border-radius:9px;padding:9px 11px;
font-size:14px;outline:none;margin-bottom:9px;color:var(--ink);background:#fff}
.chInp:focus{border-color:#146b7a;box-shadow:0 0 0 3px rgba(20,107,122,.09)}
.chLbl{font-size:12px;color:var(--sub);margin:2px 0 4px}
.chRow{display:flex;gap:8px}
.chRow .chInp{flex:1;min-width:0}
.pickBox{background:#f5f9fa;border:1px dashed #b9cdd3;border-radius:10px;padding:11px;
font-size:12.5px;color:#43536b;margin-bottom:10px;line-height:1.7}
.chBtn{width:100%;border:0;border-radius:10px;padding:12px;font-size:14.5px;font-weight:600;
cursor:pointer;background:#146b7a;color:#fff}
.chBtn:active{transform:scale(.99)}
.chBtn.line{background:#fff;border:1px solid var(--line);color:var(--ink);font-weight:500;margin-top:8px}
#scanVideo{width:100%;border-radius:10px;background:#16262f;aspect-ratio:4/3;object-fit:cover;margin-bottom:10px}
.mono{font-family:Consolas,Menlo,monospace;font-size:12.5px;color:#43536b}
</style>
</head>
<body>
<div id="phone">
<!-- ============ 登录 / 注册 ============ -->
<div id="login">
<div class="logo">🏠</div>
<h1>鹤仆云 · 守望</h1>
<p class="sub">让在外的子女,看得见家里的安稳</p>
<div id="loginForm">
<div class="field"><span>📱</span><input id="lgPhone" placeholder="手机号" inputmode="numeric" maxlength="11"></div>
<div class="field"><span>🔒</span><input id="lgPwd" type="password" placeholder="密码"></div>
<button class="btn btn-main" onclick="doLogin()">登 录</button>
<button class="btn btn-ghost" onclick="doWxLogin()">微信一键授权(模拟)</button>
<button class="btn btn-ghost" onclick="showRegister()">还没有账号?注册</button>
</div>
<div id="regForm" style="display:none">
<div class="field"><span>📱</span><input id="rgPhone" placeholder="手机号" inputmode="numeric" maxlength="11"></div>
<div class="field"><span>✉️</span><input id="rgCode" placeholder="短信验证码" inputmode="numeric" maxlength="6">
<button class="btn-mini" onclick="sendCode('REGISTER','rgPhone','rgCode')">获取验证码</button></div>
<div class="field"><span>🔒</span><input id="rgPwd" type="password" placeholder="设置密码(6 位以上)"></div>
<div class="field"><span>👤</span><input id="rgName" placeholder="称呼,如:张女士"></div>
<button class="btn btn-main" onclick="doRegister()">注册并登录</button>
<button class="btn btn-ghost" onclick="showLogin()">返回登录</button>
</div>
<div class="login-foot">
演示环境 · 验证码为随机数字,直接显示在页面上<br>
家属演示账号 13800000000 / 123456
</div>
</div>
<!-- ============ 主界面 ============ -->
<div id="app">
<!-- 实时告警横幅:收到 SSE 推送时从顶部滑入 -->
<div id="rtBanner"><div class="rtb" id="rtbBody"></div></div>
<header>
<div>
<div class="hh" id="hdSub">守望 · 安心版</div>
<div class="tt" id="hdTitle">家里今天很安静</div>
</div>
<div style="display:flex;gap:7px">
<button class="icon-btn" onclick="switchHousehold()" title="切换家庭">⌄</button>
<button class="icon-btn" onclick="refresh()" title="刷新">⟳</button>
</div>
</header>
<main id="view"></main>
<nav>
<button data-tab="home" class="on" onclick="go('home')"><span class="ic">🏠</span>首页</button>
<button data-tab="alarm" onclick="go('alarm')"><span class="ic">🔔</span>告警<span id="navDot" class="dot" style="display:none"></span></button>
<button data-tab="device" onclick="go('device')"><span class="ic">📡</span>设备</button>
<button data-tab="mine" onclick="go('mine')"><span class="ic">👤</span>我的<span id="navDot2" class="dot" style="display:none"></span></button>
</nav>
</div>
<div id="sheet" onclick="if(event.target.id==='sheet')closeSheet()">
<div class="panel" id="sheetBody"></div>
</div>
<!-- 地图选点:独立 HTML(腾讯地图 GL JS,代理模式;不可用时自动降级为坐标板) -->
<div id="mapLayer" style="display:none;position:absolute;inset:0;z-index:40;background:#fff">
<iframe id="mapFrame" src="about:blank" style="width:100%;height:100%;border:0" title="选择家的位置"></iframe>
</div>
</div>
<script src="../common.js"></script>
<script>
HB.setNS('family');
/* =====================================================================
家属端(小程序 H5 演示版)
—— 所有数据走 /api/v1/**,就是把小程序要调的接口原样调了一遍。
===================================================================== */
const S = { hh: null, households: [], elders: [], devices: [], alarms: [], notifies: [], applications: [], tab: 'home', alarmFilter: 'OPEN', pick: null };
document.addEventListener('DOMContentLoaded', () => {
// 浏览器规定:没有用户手势就不许播放声音。这里先挂一次性监听把它"解锁",
// 否则第一条 SOS 来的时候浏览器会静默拦掉提示音,等于没响。
document.addEventListener('pointerdown', () => {
if (!_audioCtx) {
try { _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch (_) {}
}
}, { once: true });
if (HB.logged()) boot(); else document.getElementById('login').classList.remove('hide');
// ?reg=1 直接翻到注册表单:给别人演示"新用户怎么注册"时,省掉先点一下"还没有账号"
if (new URLSearchParams(location.search).get('reg') === '1') showRegister();
});
/* ---------------- 登录 ----------------
code 传 "openid:xxx" 是演示用的旁路:真实小程序里 code 是微信给的临时码,
后端拿它去换 openid。这里后端直接认这个格式,省掉真微信那一步。 */
async function doWxLogin() {
try {
const d = await HB.post('/api/v1/auth/wx/login', { code: 'openid:demo-family' });
if (d.needRegister) { HB.toast('这个微信还没注册', 'err'); showRegister(); return; }
HB.setSession(d.token, d.user || { name: '张女士' });
boot();
} catch (e) { HB.toast(e.message, 'err'); }
}
async function doLogin() {
const phone = document.getElementById('lgPhone').value.trim();
const pwd = document.getElementById('lgPwd').value;
if (!phone || !pwd) return HB.toast('手机号和密码都要填', 'err');
try {
const d = await HB.post('/api/v1/auth/wx/password-login', { phone, password: pwd });
HB.setSession(d.token, d.user || {});
boot();
} catch (e) { HB.toast(e.message, 'err'); }
}
async function sendCode(scene, inputId, targetId) {
const phone = document.getElementById(inputId).value.trim();
if (!/^1\d{10}$/.test(phone)) return HB.toast('手机号不对', 'err');
try {
const d = await HB.post('/api/v1/auth/wx/sms', { phone, scene });
// 演示环境直接把码填进去,省得去翻日志
if (d && d.code) {
document.getElementById(targetId).value = d.code;
HB.toast('验证码 ' + d.code + '(演示环境直接填好了)');
} else HB.toast('验证码已发送');
} catch (e) { HB.toast(e.message, 'err'); }
}
async function doRegister() {
const phone = document.getElementById('rgPhone').value.trim();
const smsCode = document.getElementById('rgCode').value.trim();
const password = document.getElementById('rgPwd').value;
const nickname = document.getElementById('rgName').value.trim();
if (!phone || !smsCode || !password) return HB.toast('三项都要填', 'err');
try {
const d = await HB.post('/api/v1/auth/wx/register', {
openid: 'h5_' + phone, phone, password, smsCode, nickname: nickname || '家属', userType: 'FAMILY',
});
HB.setSession(d.token, d.user || { name: nickname });
boot();
} catch (e) { HB.toast(e.message, 'err'); }
}
function showRegister() {
document.getElementById('loginForm').style.display = 'none';
document.getElementById('regForm').style.display = 'block';
}
function showLogin() {
document.getElementById('loginForm').style.display = 'block';
document.getElementById('regForm').style.display = 'none';
}
/* ---------------- 启动 ---------------- */
/* ================= 实时告警通道(SSE 主 + 轮询兜底) =================
为什么不用"定时刷新列表":SOS 是 S1 级告警,家属要的是"响一下",
不是"过一会儿刷出来"。所以主链路是服务端推(SSE),推送挂了才退化成 5 秒轮询。
推送只保证"快",不保证"不漏":断线期间漏掉的,重连后靠 refresh() 补齐。 */
let _sse = null, _sseFails = 0, _pollTimer = null, _lastUnread = 0, _audioCtx = null;
function _sheetOpen() {
const s = document.getElementById('sheet');
return s && getComputedStyle(s).display !== 'none';
}
function startRealtime() {
stopRealtime();
const token = localStorage.getItem('hb_family_token');
if (!token) return;
if (!window.EventSource) { startPolling(); return; }
try {
// EventSource 不能带 Authorization 头,token 走 query(后端拦截器本来就支持)
_sse = new EventSource('/api/v1/notifications/stream?token=' + encodeURIComponent(token));
_sse.addEventListener('hello', () => { _sseFails = 0; stopPolling(); });
_sse.addEventListener('alarm', (e) => {
let d = {};
try { d = JSON.parse(e.data); } catch (_) { return; }
onRealtimeAlarm(d);
});
_sse.onerror = () => {
_sseFails++;
// readyState=2 是 CLOSED(比如 token 失效被 401),别让它无限重连
if (_sse.readyState === 2 || _sseFails >= 3) {
try { _sse.close(); } catch (_) {}
_sse = null;
startPolling();
}
};
} catch (e) {
startPolling();
}
}
function stopRealtime() {
if (_sse) { try { _sse.close(); } catch (_) {} _sse = null; }
stopPolling();
}
function startPolling() {
if (_pollTimer) return;
_pollTimer = setInterval(async () => {
try {
const u = await HB.get('/api/v1/notifications/unread');
const n = Number((u && u.unread) || 0);
if (n > _lastUnread) {
// 轮询只知道"多了未读",拿不到具体内容,所以拉一次列表再挑最新的
const r = await HB.get('/api/v1/alarms?size=5').catch(() => null);
const list = (r && r.list) || [];
const fresh = list.find(a => HB.isOpen && HB.isOpen(a.status)) || list[0];
if (fresh) onRealtimeAlarm({ alarmId: fresh.id, level: fresh.level, title: fresh.title, content: fresh.content, householdId: fresh.householdId, _viaPoll: true });
else if (!_sheetOpen()) refresh();
}
_lastUnread = n;
} catch (_) { /* 网络抖动忽略,下轮再来 */ }
}, 5000);
}
function stopPolling() {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
}
function onRealtimeAlarm(d) {
showAlarmBanner(d);
buzz(d.level);
// 页面没被弹层挡住就刷新数据,把新告警并进列表和角标
if (!_sheetOpen()) { refresh().catch(() => {}); }
}
function showAlarmBanner(d) {
const lv = d.level || 'S1';
const s1 = lv === 'S1';
const box = document.getElementById('rtBanner');
const body = document.getElementById('rtbBody');
if (!box || !body) return;
// 用 String 比:SSE 推过来的 householdId 可能是数字也可能是字符串,严格 === 会漏配
const hh = (S.households || []).find(h => String(h.id) === String(d.householdId));
const who = hh ? (hh.name || hh.community_name || hh.address_text || '我的家') : '';
body.className = 'rtb' + (s1 ? ' pulse' : ' s2');
body.innerHTML =
'<div class="rtb-h"><span class="lv">' + lv + '</span>'
+ '<span>' + (s1 ? '紧急告警' : '重要提醒') + '</span>'
+ '<span class="tm">' + (who ? HB.esc(who) + ' · ' : '') + '刚刚</span></div>'
+ '<div class="rtb-t">' + (s1 ? '🚨 ' : '⚠️ ') + HB.esc(d.title || '收到告警') + '</div>'
// 哪台设备触发的。断电这类必须写清楚是哪台,光写"设备断电"家属不知道该去看哪儿。
+ ((d.deviceName || d.deviceSn)
? '<div class="rtb-c" style="opacity:.9">' + HB.esc((d.deviceName || '') +
(d.deviceSn ? (d.deviceName ? ' · ' : '') + d.deviceSn : '')) + '</div>'
: '')
+ (d.content ? '<div class="rtb-c">' + HB.esc(d.content) + '</div>' : '')
+ '<div class="rtb-a">'
+ (d.alarmId ? '<button class="p" onclick="bannerGo(' + d.alarmId + ')">查看详情</button>' : '')
+ '<button class="s" onclick="hideAlarmBanner()">我知道了</button></div>';
box.classList.add('on');
clearTimeout(showAlarmBanner._t);
// S1 不自动收:最高级别告警不能自己消失,必须有人点一下
if (!s1) showAlarmBanner._t = setTimeout(hideAlarmBanner, 12000);
}
function hideAlarmBanner() {
const box = document.getElementById('rtBanner');
if (box) box.classList.remove('on');
}
function bannerGo(id) {
hideAlarmBanner();
go('alarm');
setTimeout(() => { if (typeof openAlarm === 'function') openAlarm(id); }, 260);
}
/* 震动与提示音:手机震动靠 navigator.vibrate,桌面端没有就只响铃 */
function buzz(level) {
const s1 = (level || 'S1') === 'S1';
try { if (navigator.vibrate) navigator.vibrate(s1 ? [260, 130, 260, 130, 260] : [180]); } catch (_) {}
beep(s1 ? 3 : 1);
}
function beep(times) {
try {
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_audioCtx.state === 'suspended') _audioCtx.resume();
const ac = _audioCtx, t0 = ac.currentTime;
for (let i = 0; i < times; i++) {
const t = t0 + i * 0.32;
const o = ac.createOscillator(), g = ac.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(880, t);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.22, t + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.24);
o.connect(g); g.connect(ac.destination);
o.start(t); o.stop(t + 0.26);
}
} catch (_) { /* 浏览器没解锁音频就静默跳过,不弹错误 */ }
}
async function boot() {
document.getElementById('login').classList.add('hide');
document.getElementById('app').classList.add('show');
await refresh();
// nort=1 时不建 SSE 长连接。用于截图、验收、以及"只想安静看一条告警"的分享页——
// 长连接挂着会让页面永远不进入 idle,自动截图和部分低端浏览器会一直转圈。
const qs = new URLSearchParams(location.search);
if (!qs.has('nort')) {
startRealtime(); // 登录成功后挂实时通道,推送挂了自动转轮询
}
// ?tab=home|alarm|device|mine 直接落到某个页签:演示逐屏讲解、外部链接直指某屏时用
const tab = qs.get('tab');
if (tab === 'alarm' || tab === 'device' || tab === 'mine' || tab === 'home') go(tab);
// 支持 ?open=<告警id> 直接打开某条告警(方便把"这一条"发给家人看,也方便验收)
const want = qs.get('open');
if (want && Number(want) > 0) {
go('alarm');
openAlarm(Number(want));
}
// ?sheet=pref 直接弹通知偏好:演示时要单独讲免打扰,不想让人从"我的"里一层层点
if (qs.get('sheet') === 'pref') {
go('mine');
editPref();
}
// ?sheet=bind 直接弹绑定设备:演示"扫码添加"和截图时不用先点两层
// 可以再带 &sn=A1B2C3D4,自动切到手填页并带出类型
if (qs.get('sheet') === 'bind') {
go('device');
setTimeout(() => {
if (typeof openBindDevice === 'function') openBindDevice();
const wanted = qs.get('sn');
if (wanted && typeof fillDemoSn === 'function') setTimeout(() => fillDemoSn(wanted), 250);
}, 300);
}
// ?demo=1 演示模式:把库里最近一条 S1 告警"重放"一次成推送横幅。
// 只为离线演示和截图存在——真实环境这条横幅由 SSE 推过来,正常路径走不到这里。
if (qs.get('demo')) {
setTimeout(function () {
const a = (S.alarms || []).filter(function (x) { return x.level === 'S1'; })[0]
|| (S.alarms || [])[0];
if (a) {
showAlarmBanner({ level: a.level, title: a.title, content: a.content,
deviceName: a.device_name, deviceSn: a.device_sn,
alarmId: a.id, householdId: (S.hh || {}).id });
}
}, 700);
}
}
function go(tab) {
S.tab = tab;
document.querySelectorAll('nav button').forEach(b => b.classList.toggle('on', b.dataset.tab === tab));
render();
}
async function refresh() {
document.getElementById('view').innerHTML = '<div class="sk" style="height:120px"></div>'
+ '<div class="sk" style="height:74px;margin-top:12px"></div>';
try {
const hs = await HB.get('/api/v1/households');
S.households = hs || [];
const saved = Number(localStorage.getItem('hb_family_hh') || 0);
// 没手动选过时,默认落到「有老人或有设备」的那个家,避免新开的空户把首屏顶掉
S.hh = S.households.find(h => h.id === saved)
|| S.households.find(h => (h.elder_count || 0) > 0)
|| S.households.find(h => (h.device_count || 0) > 0)
|| S.households[0] || null;
if (!S.hh) {
document.getElementById('view').innerHTML =
'<div class="empty"><span class="ic">🏚</span>这个账号还没有家<br>'
+ '<span style="font-size:12px">先创建一个家,才能绑定设备、接收告警</span>'
+ '<div style="margin-top:18px"><button class="btn" style="width:auto;padding:11px 26px;background:#146b7a;color:#fff" '
+ 'onclick="openCreateHouse()">+ 创建我的家</button></div></div>';
return;
}
const hid = S.hh.id;
const [detail, elders, devices, alarms, notifies, applications, unread] = await Promise.all([
HB.get('/api/v1/households/' + hid).catch(() => null),
HB.get('/api/v1/households/' + hid + '/elders').catch(() => []),
HB.get('/api/v1/households/' + hid + '/devices').catch(() => []),
HB.get('/api/v1/alarms?size=50').catch(() => ({ list: [] })),
HB.get('/api/v1/notifications?size=20').catch(() => ({ list: [] })),
HB.get('/api/v1/households/' + hid + '/applications').catch(() => []),
HB.get('/api/v1/notifications/unread').catch(() => ({ unread: 0 })),
]);
S.detail = detail || {};
S.elders = elders || [];
S.devices = devices || [];
S.alarms = alarms.list || alarms || [];
S.notifies = notifies.list || notifies || [];
S.applications = applications || [];
S.unread = Number((unread && unread.unread) || 0);
render();
} catch (e) {
document.getElementById('view').innerHTML = '<div class="empty"><span class="ic">⚠️</span>' + HB.esc(e.message) + '</div>';
}
}
/* ---------------- 派生数据 ---------------- */
const openAlarms = () => S.alarms.filter(a => HB.isOpen(a.status));
const onlineDevices = () => S.devices.filter(d => (d.online_status || '') === 'ONLINE');
function hhName() {
const h = S.hh || {};
const parts = [h.community_name, h.building_name, h.unit_name, h.room_no].filter(Boolean);
return parts.length ? parts.join(' ') : '我的家';
}
function lastActivity() {
let t = null;
S.devices.forEach(d => {
const v = d.last_seen_at;
if (v && (!t || String(v) > String(t))) t = v;
});
return t;
}
/* ---------------- 渲染 ---------------- */
function render() {
const v = document.getElementById('view');
if (S.tab === 'home') v.innerHTML = viewHome();
else if (S.tab === 'alarm') v.innerHTML = viewAlarms();
else if (S.tab === 'device') v.innerHTML = viewDevices();
else v.innerHTML = viewMine();
const n = openAlarms().length;
const dot = document.getElementById('navDot');
dot.style.display = n ? 'block' : 'none';
dot.textContent = n > 99 ? '99+' : n;
const nd2 = document.getElementById('navDot2');
if (nd2) {
nd2.style.display = S.unread ? 'block' : 'none';
nd2.textContent = S.unread > 99 ? '99+' : S.unread;
}
const ttl = document.getElementById('hdTitle');
const sub = document.getElementById('hdSub');
sub.textContent = hhName();
ttl.textContent = n === 0 ? '家里今天很安静' : (n + ' 条告警待处理');
if (S.tab === 'alarm') { ttl.textContent = '告警记录'; }
if (S.tab === 'device') { ttl.textContent = '家里的设备'; }
if (S.tab === 'mine') { ttl.textContent = '我的'; }
}
function elderCard() {
const e = S.elders[0];
if (!e) {
return '<div class="card"><div class="empty" style="padding:26px 0"><span class="ic">👵</span>还没有登记老人信息<br><span style="font-size:12px">请联系社区补录</span></div></div>';
}
const age = e.birth_date ? (new Date().getFullYear() - parseInt(e.birth_date)) : null;
const la = lastActivity();
const care = { SELF: '自理', HALF: '半自理', FULL: '不能自理' }[e.care_level] || '';
const name = e.name || '老人';
return '<div class="elder"><div class="row">'
+ '<div class="avatar">' + HB.esc(name.slice(0, 1)) + '</div>'
+ '<div style="flex:1">'
+ '<div class="nm">' + HB.esc(name) + (age ? ' · ' + age + ' 岁' : '') + '</div>'
+ '<div class="meta">' + [care, e.live_alone ? '独居' : ''].filter(Boolean).join(' · ') + '</div>'
+ '</div></div>'
+ '<div class="state"><span class="live-dot"></span>'
+ (la ? '最近有活动 · ' + HB.ago(la) : '暂无设备上报')
+ '</div></div>';
}
function viewHome() {
const open = openAlarms();
const devs = S.devices;
const on = onlineDevices().length;
const recent = open.slice(0, 2);
let html = elderCard();
html += '<div class="stats">'
+ '<div class="stat ok"><div class="n">' + on + '/' + devs.length + '</div><div class="l">设备在线</div></div>'
+ '<div class="stat ' + (open.length ? 'warn' : 'ok') + '"><div class="n">' + open.length + '</div><div class="l">待处理告警</div></div>'
+ '<div class="stat"><div class="n">' + S.elders.length + '</div><div class="l">登记老人</div></div>'
+ '</div>';
html += '<div class="quick">'
+ '<button onclick="go(\'alarm\')"><span>🔔</span>告警</button>'
+ '<button onclick="go(\'device\')"><span>📡</span>设备</button>'
+ '<button onclick="callFamily()"><span>📞</span>联系人</button>'
+ '<button onclick="go(\'mine\')"><span>⚙️</span>设置</button>'
+ '</div>';
if (recent.length) {
html += '<div class="card-t" style="padding:0 4px"><b>需要看一眼</b><span>' + open.length + ' 条</span></div>';
recent.forEach(a => { html += alarmItem(a); });
}
html += '<div class="card"><div class="card-t"><b>家里的设备</b><span>' + devs.length + ' 台</span></div>';
if (!devs.length) html += '<div class="empty" style="padding:20px 0">还没有绑定设备</div>';
devs.slice(0, 4).forEach(d => { html += devItem(d); });
if (devs.length > 4) html += '<div style="text-align:center;padding-top:10px;font-size:12.5px;color:#6b7784" onclick="go(\'device\')">查看全部 ' + devs.length + ' 台 →</div>';
html += '</div>';
return html;
}
function devItem(d) {
const t = HB.typeInfo(d.type_code);
const st = d.online_status || 'UNKNOWN';
const stCls = st === 'ONLINE' ? 't-on' : st === 'OFFLINE' ? 't-off' : 't-unk';
const stTxt = st === 'ONLINE' ? '在线' : st === 'OFFLINE' ? '离线' : '未知';
const bat = d.battery_mv ? Math.round((d.battery_mv - 3000) / 12) : null;
return '<div class="dev" onclick="openDevice(\'' + HB.esc(d.dev_id) + '\')">'
+ '<div class="ic" style="color:' + t.color + '">' + t.icon + '</div>'
+ '<div class="info"><div class="nm">' + HB.esc(d.display_name || t.name) + '</div>'
+ '<div class="sub">' + (d.install_location ? HB.esc(d.install_location) + ' · ' : '') + HB.ago(d.last_seen_at) + '</div></div>'
+ '<div class="rt"><span class="tag ' + stCls + '">' + stTxt + '</span>'
+ (bat !== null && bat > 0 ? '<div style="margin-top:3px">🔋' + Math.min(bat, 100) + '%</div>' : '')
+ '</div></div>';
}
// 设备显示名:别名(用户自己起的)+ 机身编号 SN。
// 不显示 dev_id——那是系统内部号(T-G-03 / D1794…),家属手上和对单时都没有。
function devLabel(a) {
const name = a.device_name || '';
const sn = a.device_sn || '';
if (name && sn) return name + ' · ' + sn;
return name || sn || '—';
}
function alarmItem(a) {
const lv = HB.levelInfo(a.level);
const open = HB.isOpen(a.status);
return '<div class="alarm ' + lv.cls + '" onclick="openAlarm(' + a.id + ')">'
+ '<div class="bar"></div><div class="bd">'
+ '<div class="ti"><span>' + HB.esc(a.title) + '</span><em>' + HB.ago(a.trigger_at) + '</em></div>'
+ '<div class="ct">' + HB.esc(a.content || '') + '</div>'
+ '<div class="mt"><span class="chip ' + (open ? 'open' : 'done') + '">'
+ (HB.STATUS[a.status] || a.status) + '</span>'
// 本组第几次:只显示最新那条时,家属会以为"就刚刚这一次"。
// 打出"第 5 次"他才会点进去看最早是什么时候开始的。
+ (Number(a.chain_seq) > 1 ? '<span class="chip multi">第 ' + a.chain_seq + ' 次</span>' : '')
// 已恢复是"附加状态",不是"处理完了":水退了、门关了,但还没人确认过。
// 所以状态标签照样是"新告警",旁边多一个绿色的"已恢复"。2026-11-09
+ (a.cleared_at ? '<span class="chip rec">已恢复</span>' : '')
+ (a.status === 'DELAYED' ? '<span class="chip quiet">免打扰,不响铃</span>' : '')
+ '<span>' + lv.name + '</span>'
// 设备:用别名 + 机身编号。dev_id(T-G-03 / D1794…)是我们内部的号,家属手上没有
+ '<span>' + HB.esc(devLabel(a)) + '</span></div>'
+ '</div></div>';
}
function viewAlarms() {
const f = S.alarmFilter;
const list = S.alarms.filter(a => f === 'ALL' ? true : f === 'OPEN' ? HB.isOpen(a.status) : !HB.isOpen(a.status));
let html = '<div class="seg">'
+ '<button class="' + (f === 'OPEN' ? 'on' : '') + '" onclick="setFilter(\'OPEN\')">未处理</button>'
+ '<button class="' + (f === 'ALL' ? 'on' : '') + '" onclick="setFilter(\'ALL\')">全部</button>'
+ '<button class="' + (f === 'DONE' ? 'on' : '') + '" onclick="setFilter(\'DONE\')">已处理</button>'
+ '</div>';
if (!list.length) {
html += '<div class="empty"><span class="ic">🌤</span>没有' + (f === 'OPEN' ? '待处理的' : '') + '告警<br><span style="font-size:12px">这段时间家里一切正常</span></div>';
} else {
list.forEach(a => { html += alarmItem(a); });
}
return html;
}
function setFilter(f) { S.alarmFilter = f; render(); }
function viewDevices() {
let html = '<button class="btn" style="background:#146b7a;color:#fff;margin-bottom:12px" '
+ 'onclick="openBindDevice()">+ 绑定设备(扫码 / 手填编号)</button>';
html += '<div class="stats">'
+ '<div class="stat ok"><div class="n">' + onlineDevices().length + '</div><div class="l">在线</div></div>'
+ '<div class="stat"><div class="n">' + (S.devices.length - onlineDevices().length) + '</div><div class="l">离线/未知</div></div>'
+ '<div class="stat"><div class="n">' + S.devices.length + '</div><div class="l">合计</div></div>'
+ '</div>';
if (!S.devices.length) {
return html + '<div class="empty"><span class="ic">📡</span>还没有设备<br><span style="font-size:12px">扫设备上的码就能绑定</span></div>';
}
const groups = {};
S.devices.forEach(d => {
const k = d.install_location || '未标注位置';
(groups[k] = groups[k] || []).push(d);
});
Object.keys(groups).forEach(k => {
html += '<div class="card"><div class="card-t"><b>' + HB.esc(k) + '</b><span>' + groups[k].length + ' 台</span></div>';
groups[k].forEach(d => { html += devItem(d); });
html += '</div>';
});
return html;
}
function viewMine() {
const u = HB.user();
const name = (u && (u.name || u.nickname)) || '家属';
const h = S.hh || {};
const elder = S.elders[0];
let html = '<div class="card" style="display:flex;align-items:center;gap:12px">'
+ '<div class="avatar" style="background:#e8f3f6;color:#146b7a">' + HB.esc(name.slice(0, 1)) + '</div>'
+ '<div style="flex:1"><div style="font-size:16px;font-weight:600">' + HB.esc(name) + '</div>'
+ '<div style="font-size:12px;color:#6b7784">家属 · 已关注 ' + (S.elders.length ? 1 : 0) + ' 位老人</div></div>'
+ '</div>';
html += '<div class="card"><div class="card-t"><b>我的家庭</b>'
+ '<span style="color:#146b7a;cursor:pointer;font-size:12.5px;font-weight:600" '
+ 'onclick="openCreateHouse()">+ 创建我的家</span></div>'
+ kvRow('地址', hhName())
+ kvRow('门牌', h.room_no || h.id || '—')
+ (h.address_text ? kvRow('完整地址', h.address_text) : '')
+ (h.longitude ? kvRow('地图坐标', h.longitude + ', ' + h.latitude + '(GCJ-02)') : '')
+ kvRow('老人', elder ? (elder.name || '老人') : '未登记')
+ kvRow('设备', S.devices.length + ' 台')
+ (S.households.length > 1 ? '<div style="font-size:12px;color:#6b7784;padding-top:8px">'
+ '共有 ' + S.households.length + ' 个家,点顶部 ⌄ 切换</div>' : '')
+ '</div>';
html += '<div class="card"><div class="card-t"><b>老人信息</b>'
+ '<span style="color:#146b7a;cursor:pointer;font-size:12.5px;font-weight:600" '
+ 'onclick="openElderForm()">+ 填写老人信息</span></div>';
if (!S.elders.length) {
html += '<div class="empty" style="padding:16px 0">还没有登记老人<br>'
+ '<span style="font-size:12px">填了之后才能关联设备和紧急联系人</span></div>';
}
S.elders.forEach(e => {
const care = { SELF: '自理', HALF: '半自理', FULL: '不能自理' }[e.care_level] || '';
html += '<div class="dev" onclick="openElderDetail(' + e.id + ')">'
+ '<div class="ic">👵</div><div class="info"><div class="nm">' + HB.esc(e.name || '老人') + '</div>'
+ '<div class="sub">' + [care, e.live_alone ? '独居' : ''].filter(Boolean).join(' · ')
+ (e.mobileMasked ? ' · ' + HB.esc(e.mobileMasked) : '') + '</div></div>'
+ '<div class="rt">›</div></div>';
});
html += '</div>';
const nApp = (S.applications || []).length;
html += '<div class="card"><div class="card-t"><b>家人与成员</b>'
+ '<span style="color:#146b7a;cursor:pointer;font-size:12.5px;font-weight:600" '
+ 'onclick="openJoin()">加入别人的家</span></div>'
+ '<div class="quick" style="margin:0">'
+ '<button onclick="openInvite()"><span>🎟️</span>邀请家人</button>'
+ '<button onclick="openMembers()"><span>👥</span>成员</button>'
+ '<button onclick="openApplications()"><span>📥</span>审批'
+ (nApp ? '<b style="color:#d94b4b">(' + nApp + ')</b>' : '') + '</button>'
+ '<button onclick="openHouseContacts()"><span>📞</span>紧急联系人</button>'
+ '</div></div>';
html += '<div class="card"><div class="card-t"><b>通知设置</b><span>免打扰 / 接收范围</span></div>'
+ '<div class="dev" onclick="editPref()"><div class="ic">🔕</div><div class="info">'
+ '<div class="nm">通知偏好</div><div class="sub">夜间免打扰、只收重要告警</div></div>'
+ '<div class="rt">›</div></div>'
+ '</div>';
html += '<div class="card"><div class="card-t"><b>消息</b>'
+ '<span>' + (S.unread ? S.unread + ' 条未读' : '全部已读')
+ (S.unread ? ' <span style="color:#146b7a;cursor:pointer;font-weight:600" onclick="readAllNotify()">全部已读</span>' : '')
+ '</span></div>';
if (!S.notifies.length) html += '<div class="empty" style="padding:18px 0">暂无消息</div>';
S.notifies.slice(0, 5).forEach(n => {
html += '<div class="dev" onclick="readNotify(' + n.id + ')"><div class="ic">📨</div>'
+ '<div class="info"><div class="nm">' + HB.esc(npayload(n)) + '</div>'
+ '<div class="sub">' + HB.ago(n.created_at) + '</div></div>'
+ '<div class="rt">' + (n.read_at ? '' : '<span class="tag t-unk">未读</span>') + '</div></div>';
});
html += '</div>';
html += '<div style="text-align:center;padding:14px 0 6px"><button class="btn btn-ghost" '
+ 'style="background:#f2f4f6;color:#c0392b;border-color:#f2f4f6" onclick="HB.logout()">退出登录</button></div>';
return html;
}
function kvRow(k, v) {
return '<div class="kv"><div class="k">' + k + '</div><div class="v">' + HB.esc(v) + '</div></div>';
}
function npayload(n) {
try {
const p = typeof n.payload === 'string' ? JSON.parse(n.payload) : n.payload;
return p.title || n.template_code || '通知';
} catch (e) { return n.template_code || '通知'; }
}
async function readNotify(id) {
try { await HB.post('/api/v1/notifications/' + id + '/read'); HB.toast('已读'); refresh(); }
catch (e) { HB.toast(e.message, 'err'); }
}
async function readAllNotify() {
try { await HB.post('/api/v1/notifications/read-all'); HB.toast('全部已读'); refresh(); }
catch (e) { HB.toast(e.message, 'err'); }
}
async function callFamily() {
const e = S.elders[0];
if (!e) return HB.toast('还没有老人信息', 'err');
try {
const cs = await HB.get('/api/v1/elders/' + e.id + '/contacts');
let html = '<div class="sheet-h"><h3>联系人</h3><button onclick="closeSheet()">✕</button></div>';
if (!cs || !cs.length) html += '<div class="empty">还没填联系人</div>';
(cs || []).forEach(c => {
html += '<div class="dev"><div class="ic">👤</div><div class="info"><div class="nm">'
+ HB.esc(c.name || '联系人') + ' <span class="chip">' + HB.esc(c.relation || '') + '</span></div>'
+ '<div class="sub">' + HB.maskPhone(c.mobile || c.phone) + '</div></div>'
+ '<div class="rt">' + (c.mobile ? '<a href="tel:' + HB.esc(c.mobile) + '" style="color:#146b7a">拨打</a>' : '') + '</div></div>';
});
showSheet(html);
} catch (e2) { HB.toast(e2.message, 'err'); }
}
async function editPref() {
let pref = {};
try { pref = await HB.get('/api/v1/notify/preferences?householdId=' + (S.hh ? S.hh.id : '')) || {}; }
catch (e) { HB.toast(e.message, 'err'); return; }
const on = !!pref.enabled;
const html = '<div class="sheet-h"><h3>通知偏好</h3><button onclick="closeSheet()">✕</button></div>'
+ '<div class="sw-row"><div><b>夜间免打扰</b>'
+ '<div class="sub">这个时段收到的<b>普通</b>告警只进列表,不弹窗、不响铃、不发短信</div></div>'
+ '<label class="sw"><input type="checkbox" id="qon"' + (on ? ' checked' : '')
+ ' onchange="toggleQuiet()"><i></i></label></div>'
+ '<div id="qbox"' + (on ? '' : ' style="display:none"') + '>'
+ '<div class="kv"><div class="k">开始时间</div><div class="v">'
+ '<input type="time" id="qs" value="' + HB.esc(pref.quietStart || '22:00') + '"></div></div>'
+ '<div class="kv"><div class="k">结束时间</div><div class="v">'
+ '<input type="time" id="qe" value="' + HB.esc(pref.quietEnd || '07:00') + '"></div></div>'
+ '</div>'
+ '<div class="tip-s1">🚨 SOS、水浸这类<b>紧急告警不受免打扰限制</b>,几点都会弹窗、都会发短信。'
+ '这条不能改,是产品硬规矩。</div>'
+ '<div style="font-size:12px;color:#8a949e;margin:6px 0 0;line-height:1.6">'
+ '只影响<b>你自己这台手机</b>。家里其他人各设各的,不会互相覆盖。</div>'
+ '<div class="acts"><button class="pri" onclick="savePref()">保存</button></div>';
showSheet(html);
}
function toggleQuiet() {
document.getElementById('qbox').style.display = document.getElementById('qon').checked ? '' : 'none';
}
async function savePref() {
const on = document.getElementById('qon').checked;
const qs = document.getElementById('qs').value;
const qe = document.getElementById('qe').value;
if (on) {
if (!qs || !qe) { HB.toast('请把开始和结束时间都选上', 'err'); return; }
if (qs === qe) { HB.toast('开始和结束不能一样,那等于没有静默', 'err'); return; }
}
// 关掉时传空串:后端按"留空 = 关闭"处理,写 NULL。
// 不能传默认的 22:00——那等于"用户关掉了却被写成开着"。
const body = on
? { quietStart: qs, quietEnd: qe, types: [], channels: ['APP'] }
: { quietStart: '', quietEnd: '', types: [], channels: ['APP'] };
try {
const r = await HB.put('/api/v1/notify/preferences?householdId=' + (S.hh ? S.hh.id : ''), body);
closeSheet();
HB.toast(r && r.enabled
? '已开启:' + r.quietStart + '–' + r.quietEnd + ' 只进列表不打扰'
: '已关闭免打扰,所有告警都会响');
refresh();
} catch (e) { HB.toast(e.message, 'err'); }
}
/* 链上的时间显示"月-日 时:分:秒"。
为什么不只到分钟:同一分钟里连按三次 SOS,只显示 11:21 会看成"系统重复记了三遍",
带上秒才看得出是 34 秒、36 秒、38 秒三次真实触发。 */
function tLabel(v) {
if (!v) return '—';
const s = String(v).replace('T', ' ');
return s.length >= 19 ? s.slice(5, 19) : s;
}
/* 把"同一事件连续触发的那一组"画成时间轴。只有一条时不画。 */
function chainHtml(ch, curId) {
if (!ch || !ch.items || ch.items.length <= 1) return '';
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">' + tLabel(it.trigger_at) + '</span>'
+ '<span class="cs">' + (HB.STATUS[it.status] || it.status) + '</span>'
+ (me ? '<span class="cm">本条</span>' : '') + '</div>';
}).join('');
// 超过 4 条才提示可滑(4 条正好是限高内可见的数量)
const more = ch.items.length > 4
? '<span class="chain-more">共 ' + ch.items.length + ' 条,上下滑</span>' : '';
// 时间范围不在这里重复:详情上方那行"本组从 X 开始…最近一次 Y"已经说过了
return '<div class="chain">'
+ '<div class="chain-h">历史触发记录' + more + '</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';
const multi = Number(ch.count || 0) > 1;
let html = '<div class="sheet-h"><h3>' + HB.esc(a.title) + '</h3><button onclick="closeSheet()">✕</button></div>';
// 被替代的单子:先告诉用户"你翻到的是旧的",并给一个直达最新的入口
if (sup) {
html += '<div class="sup-tip"><span>这条已被后来的告警替代,不能单独处理</span>'
+ '<button onclick="openAlarm(' + (ch.latestId || id) + ')">查看最新</button></div>';
}
// 一组多次触发时,把"最开始什么时候"顶到最上面——这是家属最容易误判的地方:
// 只看到 14:32 会以为门是刚开的,其实 07:15 就开过,中间一直被替代。
// 首末同一天时最近一次只写时分秒,否则这句话会折成两行,白占高度。
if (multi && !sup) {
const sameDay = String(ch.firstAt).slice(0, 10) === String(ch.lastAt).slice(0, 10);
const lastTxt = sameDay
? String(ch.lastAt).replace('T', ' ').slice(11, 19)
: tLabel(ch.lastAt);
html += '<div class="chain-h" style="margin-bottom:8px">⚠️ 本组从 <b>' + tLabel(ch.firstAt)
+ '</b> 起触发 <b>' + ch.count + '</b> 次,最近 <b>' + lastTxt + '</b></div>';
}
html += '<div style="font-size:13px;color:#6b7784;margin-bottom:12px">' + HB.esc(a.content || '') + '</div>'
+ kvRow('级别', HB.levelInfo(a.level).name)
+ kvRow('状态', HB.STATUS[a.status] || a.status)
+ (a.cleared_at
? kvRow('设备状态', '已恢复 · ' + HB.dt(a.cleared_at)
+ (a.clear_event_type ? '(' + HB.evName(a.clear_event_type) + ')' : ''))
: '')
+ kvRow('设备', devLabel(a))
+ kvRow('触发时间', HB.dt(a.trigger_at))
+ kvRow('单号', a.alarm_no || '—');
html += chainHtml(ch, id);
// 只有最新那条能改状态(后端也会拦,这里只是不给按钮,别让用户白点)
let acts = '';
if (!sup && HB.isOpen(a.status)) {
if (['NEW', 'PUSHED', 'ESCALATED', 'DELAYED'].includes(a.status)) {
acts += '<button class="pri" onclick="alarmAct(' + id + ',\'confirm\')">我知道了</button>';
}
if (['NEW', 'PUSHED', 'CONFIRMED', 'ESCALATED', 'DELAYED'].includes(a.status)) {
acts += '<button onclick="alarmAct(' + id + ',\'handle\')">已处理</button>';
}
acts += '<button class="dan" onclick="alarmAct(' + id + ',\'false\')">误报</button>';
}
if (acts) html += '<div class="acts sticky">' + acts + '</div>';
html += '<div class="card-t" style="margin-top:16px"><b>处理记录</b></div><div id="acts2" class="tl">'
+ '<div class="it"><div class="d">加载中…</div></div></div>';
showSheet(html);
try {
const list = await HB.get('/api/v1/alarms/' + id + '/actions');
const box = document.getElementById('acts2');
if (!box) return;
if (!list || !list.length) {
box.innerHTML = '<div class="it"><div class="w">' + HB.dt(a.trigger_at) + '</div><div class="d">告警触发</div></div>';
} else {
box.innerHTML = list.map(x => '<div class="it f"><div class="w">' + HB.dt(x.created_at)
+ (x.operator_name ? ' · ' + HB.esc(x.operator_name) : '') + '</div>'
+ '<div class="d">' + HB.esc(x.remark || x.action) + '</div></div>').join('');
}
} catch (e) { /* 忽略 */ }
}
async function alarmAct(id, kind) {
const labels = { confirm: '已确认', handle: '已标记处理', close: '已关闭', false: '已标为误报' };
const paths = { confirm: 'confirm', handle: 'handle', close: 'close', false: 'false' };
const body = kind === 'false' ? { reason: '家属判定为误报' } : { remark: '家属操作' };
try {
await HB.post('/api/v1/alarms/' + id + '/' + paths[kind], body);
closeSheet(); HB.toast(labels[kind]); refresh();
} catch (e) { HB.toast(e.message, 'err'); }
}
/* ---------------- 设备详情 ---------------- */
async function openDevice(devId) {
const d = S.devices.find(x => x.dev_id === devId);
if (!d) return;
const t = HB.typeInfo(d.type_code);
let html = '<div class="sheet-h"><h3>' + HB.esc(d.display_name || t.name) + '</h3><button onclick="closeSheet()">✕</button></div>'
+ '<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">'
+ '<div style="width:52px;height:52px;border-radius:14px;background:#f0f5f8;display:flex;align-items:center;justify-content:center;font-size:24px">' + t.icon + '</div>'
+ '<div><div style="font-size:14px">' + HB.esc(t.name) + ' · ' + HB.esc(d.vendor || '') + '</div>'
+ '<div style="font-size:12px;color:#6b7784">' + HB.esc(d.sensor_id) + '</div></div></div>'
+ kvRow('状态', d.online_status === 'ONLINE' ? '在线' : d.online_status === 'OFFLINE' ? '离线' : '未知')
+ kvRow('安装位置', (d.install_location || '—') + (d.zone_no ? ' · ' + d.zone_no : ''))
+ kvRow('电量', d.battery_mv ? d.battery_mv + ' mV' : '—')
+ kvRow('信号', d.csq ? d.csq + ' CSQ' : '—')
+ kvRow('最近上报', HB.ago(d.last_seen_at))
+ kvRow('设备编号', d.sensor_id || '—');
html += '<div class="card-t" style="margin-top:14px"><b>最近事件</b></div><div id="evBox"><div class="sk" style="height:60px"></div></div>';
// 家属端两个按钮,始终显示,名字用家属的话:
// 测试 = diag_dump,只读,让设备回报一次状态
// 重启 = reboot
// 校时不放:网关每次连上都自动校时,家属点了界面上什么都不会变。
html += '<div class="acts">'
+ '<button class="pri" onclick="cmd(\'' + devId + '\',\'DIAG_DUMP\')">测试</button>'
+ '<button onclick="cmd(\'' + devId + '\',\'REBOOT\')">重启</button>'
+ '</div>'
+ '<div id="cmdState" style="font-size:12.5px;line-height:1.6;margin-top:8px;min-height:18px"></div>'
+ '<button class="chBtn line" style="color:#c0392b;border-color:#f2c4c4" '
+ 'onclick="unbindDevice(\'' + devId + '\')">把设备从这个家解绑</button>';
showSheet(html);
try {
const ev = await HB.get('/api/v1/devices/' + devId + '/events?size=8');
const rows = ev.list || ev || [];
const box = document.getElementById('evBox');
if (!box) return;
box.innerHTML = rows.length
? rows.map(e => '<div class="kv"><div class="k">' + HB.hhmm(e.event_time) + '</div>'
+ '<div class="v">' + HB.esc(HB.evName(e.event_type))
+ (e.is_backfill ? ' <span class="chip">补传</span>' : '') + '</div></div>').join('')
: '<div class="empty" style="padding:16px 0">暂无事件</div>';
} catch (e) { }
}
/** 七态 → 家属看得懂的一句话,文案与后台共用 HB.CMD_TEXT(见 common.js) */
async function cmd(devId, type) {
// 重启会让家里设备断一分钟,问一次。文案直说后果,不解释原理。
if (type === 'REBOOT' && !window.confirm(
'设备会断开约 1 分钟,这期间按紧急按钮我们收不到。\n确定要重启吗?')) {
return;
}
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.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 unbindDevice(devId) {
if (!window.confirm('解绑后这台设备就不再属于这个家,历史记录会保留。确定吗?')) return;
try {
await HB.post('/api/v1/devices/' + devId + '/unbind');
closeSheet(); HB.toast('已解绑'); refresh();
} catch (e) { HB.toast(e.message, 'err'); }
}
/* ---------------- 弹层 ---------------- */
function showSheet(html) {
document.getElementById('sheetBody').innerHTML = html;
document.getElementById('sheet').classList.add('show');
// 历史触发记录限高后,"本条"可能被滚出可视区(最新一条在列表末尾)。
// 打开时把列表滚到底,让"本条"那条直接露出来,否则用户只看到一堆"已被替代"。
const box = document.querySelector('.chain-l');
const cur = box && box.querySelector('.ci.cur');
if (cur) {
box.scrollTop = Math.max(0, cur.offsetTop + cur.offsetHeight - box.clientHeight);
}
}
function closeSheet() {
document.getElementById('sheet').classList.remove('show');
if (S.scanStream) { S.scanStream.getTracks().forEach(t => t.stop()); S.scanStream = null; }
}
/* =====================================================================
① 切换家庭
===================================================================== */
function switchHousehold() {
if (!S.households.length) return openCreateHouse();
let html = '<div class="sheet-h"><h3>切换家庭</h3><button onclick="closeSheet()">✕</button></div>';
S.households.forEach(h => {
const on = S.hh && h.id === S.hh.id;
html += '<div class="dev" onclick="pickHousehold(' + h.id + ')">'
+ '<div class="ic">' + (on ? '✅' : '🏠') + '</div>'
+ '<div class="info"><div class="nm">' + HB.esc(h.community_name || '我的家')
+ ' · ' + HB.esc(h.room_no || h.id) + '</div>'
+ '<div class="sub">' + HB.esc(h.address_text || [h.building_name, h.unit_name, h.room_no].filter(Boolean).join(' '))
+ '</div></div><div class="rt">' + (on ? '当前' : '›') + '</div></div>';
});
html += '<div class="acts"><button class="pri" onclick="openCreateHouse()">+ 再建一个家</button></div>';
showSheet(html);
}
function pickHousehold(id) {
localStorage.setItem('hb_family_hh', String(id));
closeSheet(); refresh();
}
/* =====================================================================
② 创建我的家(地址 + 地图选点 / GPS)
===================================================================== */
function openCreateHouse() {
S.pick = null;
let html = '<div class="sheet-h"><h3>创建我的家</h3><button onclick="closeSheet()">✕</button></div>'
+ '<div style="font-size:12px;color:#6b7784;margin-bottom:12px;line-height:1.7">'
+ '和新增收货地址一样:先在地图上选个位置,再填楼栋、单元、房号。'
+ '建好之后你就是这个家的户主,可以绑定设备了。</div>'
+ '<div class="pickBox" id="chPick">📍 还没选位置<br>'
+ '<span style="color:#8a949e">地图选点 / GPS 定位,坐标按 GCJ-02 存储</span></div>'
+ '<button class="chBtn line" style="margin:0 0 12px" onclick="openMapPicker()">📍 在地图上选点</button>'
+ '<div class="chLbl">省 / 市 / 区</div><div class="chRow">'
+ '<input class="chInp" id="chProv" value="广东省" placeholder="省">'
+ '<input class="chInp" id="chCity" value="中山市" placeholder="市">'
+ '<input class="chInp" id="chDist" value="东区" placeholder="区"></div>'
+ '<div class="chLbl">小区 / 大厦名称 *</div>'
+ '<input class="chInp" id="chComm" placeholder="如:鹤仆云示范小区">'
+ '<div class="chLbl">楼栋 / 单元 / 房号 *</div><div class="chRow">'
+ '<input class="chInp" id="chBuild" placeholder="1 栋">'
+ '<input class="chInp" id="chUnit" placeholder="1 单元">'
+ '<input class="chInp" id="chRoom" placeholder="101"></div>'
+ '<div class="chRow">'
+ '<input class="chInp" id="chFloor" inputmode="numeric" placeholder="楼层">'
+ '<input class="chInp" id="chDetail" placeholder="门牌补充(选填)"></div>'
+ '<div class="chLbl">完整地址(留空则自动拼)</div>'
+ '<input class="chInp" id="chAddr" placeholder="广东省 中山市 东区 …">'
+ '<button class="chBtn" onclick="submitCreateHouse()">创建并设为当前家庭</button>';
showSheet(html);
}
function openMapPicker() {
// 能联网就走百度真地图;断网直接上离线矢量版。
// 百度那页自己还有一层兜底:ak 失效 / 白名单没放行 / 加载超时,都会自动跳离线版,
// 所以这里只需要判断"有没有网",不用在这儿做复杂的探测。
const want = (navigator.onLine === false) ? 'map-offline.html' : 'map-baidu.html';
const f = document.getElementById('mapFrame');
if (f && f.getAttribute('src') !== want) f.setAttribute('src', want);
document.getElementById('mapLayer').style.display = 'block';
}
function closeMapPicker() {
document.getElementById('mapLayer').style.display = 'none';
}
window.addEventListener('message', (e) => {
const d = e.data || {};
if (d.type === 'heserv:pick-cancel') return closeMapPicker();
if (d.type !== 'heserv:pick') return;
S.pick = d;
closeMapPicker();
const box = document.getElementById('chPick');
if (box) {
box.innerHTML = '📍 <b>' + HB.esc(d.name || '已选位置') + '</b><br>'
+ HB.esc(d.addr || '') + '<br><span style="color:#146b7a;font-family:Consolas,monospace">'
+ d.lng + ', ' + d.lat + '(GCJ-02)</span>';
}
// 地图搜索回来的小区名,表单没填就顺手带上
const comm = document.getElementById('chComm');
if (comm && !comm.value && d.name) comm.value = d.name;
const addr = document.getElementById('chAddr');
if (addr && !addr.value && d.addr) addr.value = d.addr;
});
async function submitCreateHouse() {
const v = (id) => (document.getElementById(id) ? document.getElementById(id).value.trim() : '');
if (!v('chComm') || !v('chRoom')) return HB.toast('小区名称和房号要填', 'err');
const floor = parseInt(v('chFloor'), 10);
const p = S.pick;
const body = {
province: v('chProv'), city: v('chCity'), district: v('chDist'),
communityName: v('chComm'), buildingName: v('chBuild'), unitName: v('chUnit'),
roomNo: v('chRoom'), floor: isFinite(floor) ? floor : null,
addressDetail: v('chDetail'), addressText: v('chAddr'),
lng: p ? p.lng : null, lat: p ? p.lat : null,
gpsSource: p ? (String(p.addr || '').includes('GPS') ? 'GPS' : 'MAP_PICK') : null,
};
try {
const d = await HB.post('/api/v1/households', body);
if (d && d.id) localStorage.setItem('hb_family_hh', String(d.id));
closeSheet(); HB.toast('家建好了'); refresh();
} catch (e) { HB.toast(e.message, 'err'); }
}
/* =====================================================================
③ 绑定设备(扫码 / 手填编号)
===================================================================== */
const DEMO_SNS = [
['A1B2C3D4', '门磁 · 已入库待绑(可以拿它走一遍)'],
['3F7A9C22', '卧室门磁 · 已经被别的家绑走了'],
['3F7A9C99', '没入库的机器 · 会被拦住'],
];
async function openBindDevice() {
if (!S.hh) return HB.toast('先创建一个家', 'err');
let html = '<div class="sheet-h"><h3>绑定设备</h3><button onclick="closeSheet()">✕</button></div>'
+ '<div style="font-size:12px;color:#6b7784;margin-bottom:11px">绑定到:<b>'
+ HB.esc(hhName()) + '</b> 扫设备上的二维码,或手工输入机身编号。</div>'
+ '<div class="seg">'
+ '<button id="btScan" class="on" onclick="bindSwitchTab(\'scan\')">📷 扫码</button>'
+ '<button id="btManual" onclick="bindSwitchTab(\'manual\')">⌨️ 手填编号</button>'
+ '</div><div id="bindTab"></div>';
showSheet(html);
bindSwitchTab('scan');
}
function bindSwitchTab(k) {
const scan = document.getElementById('btScan'), man = document.getElementById('btManual');
if (scan) scan.classList.toggle('on', k === 'scan');
if (man) man.classList.toggle('on', k !== 'scan');
const box = document.getElementById('bindTab');
if (!box) return;
if (k === 'scan') {
box.innerHTML = '<video id="scanVideo" playsinline muted></video>'
+ '<button class="chBtn" onclick="startScan()">开启摄像头扫码</button>'
+ '<div style="font-size:11.5px;color:#8a949e;margin:10px 0;line-height:1.7">'
+ '演示环境没有真二维码,可以直接选一个示例编号;换成真设备后,摄像头扫到码会自动填进手填页。</div>'
+ '<div class="chLbl">演示设备码(点一下就填)</div>'
+ DEMO_SNS.map(s => '<div class="dev" onclick="fillDemoSn(\'' + s[0] + '\')">'
+ '<div class="ic">📦</div><div class="info"><div class="nm mono">' + HB.esc(s[0])
+ '</div><div class="sub">' + HB.esc(s[1]) + '</div></div><div class="rt">›</div></div>').join('');
} else {
box.innerHTML = '<div class="chLbl">机身编号 / SN *</div>'
+ '<input class="chInp" id="bdSn" placeholder="如 3F7A9C24 或 MIR:861234567890123" '
+ 'oninput="lookupSn()">'
+ '<div class="chLbl">设备类型</div>'
+ '<div id="bdTypeBox" class="chInp" '
+ 'style="background:#f1f5f7;color:#5a6b76;display:flex;align-items:center;gap:6px">'
+ '填了编号,这里自动带出</div>'
+ '<div class="chLbl">给它起个名(选填)</div>'
+ '<input class="chInp" id="bdName" placeholder="如:入户门 / 厨房">'
+ '<div class="chLbl">装在哪儿</div>'
+ '<input class="chInp" id="bdLoc" placeholder="如:卫生间 / 入户门">'
+ '<div class="chLbl">关联老人(选填)</div>'
+ '<select class="chInp" id="bdElder"><option value="">不指定</option>'
+ S.elders.map(e => '<option value="' + e.id + '">' + HB.esc(e.name || '老人') + '</option>').join('')
+ '</select>'
+ '<button class="chBtn" onclick="submitBind()">绑定到这个家</button>'
+ '<div style="font-size:11.5px;color:#8a949e;margin-top:9px;line-height:1.7">'
+ '类型不用选——设备出厂时运营已经录好了,绑上就是那个类型。'
+ '没入库的机器绑不了,得先让运营在后台「设备入库」里录。</div>';
if (S.bindSn) document.getElementById('bdSn').value = S.bindSn;
if (S.bindSn) lookupSn();
}
}
/* 填完编号就预查:这台入没入库、什么类型、能不能绑。
类型不再让用户选——设备出厂时运营录好了,这里只是把结果展示出来,
顺便当场拦住"没入库"和"已经被人绑了"两种情况。 */
let _snTimer = null;
function lookupSn() {
const box = document.getElementById('bdTypeBox');
if (!box) return;
clearTimeout(_snTimer);
const sn = (document.getElementById('bdSn').value || '').trim();
if (!sn) {
box.innerHTML = '填了编号,这里自动带出';
box.style.color = '#5a6b76';
return;
}
box.innerHTML = '查一下…';
box.style.color = '#8a949e';
_snTimer = setTimeout(async () => {
const cur = document.getElementById('bdTypeBox');
if (!cur) return;
try {
const r = await HB.get('/api/v1/devices/by-sn/' + encodeURIComponent(sn));
const t = HB.typeInfo(r.typeCode);
if (!r.registered) {
cur.innerHTML = '⚠️ 这台还没入库 —— 先让运营在后台「设备入库」里录';
cur.style.color = '#b26a00';
} else if (r.bound) {
cur.innerHTML = t.icon + ' ' + t.name + ' · 已经被绑走了';
cur.style.color = '#b26a00';
} else if (!r.bindable) {
cur.innerHTML = '⚠️ ' + t.icon + ' ' + t.name + ' · 状态「' + r.status + '」,不能绑';
cur.style.color = '#c0392b';
} else {
cur.innerHTML = t.icon + ' ' + t.name + ' · 已入库,可以绑';
cur.style.color = '#2f9e68';
}
} catch (e) {
cur.innerHTML = '查不了,检查一下网络';
cur.style.color = '#c0392b';
}
}, 350);
}
function fillDemoSn(sn) {
S.bindSn = sn;
bindSwitchTab('manual');
}
async function startScan() {
const v = document.getElementById('scanVideo');
if (!('BarcodeDetector' in window)) {
return HB.toast('这个浏览器不支持直接扫码,用下面的示例编号或手填', 'err');
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
S.scanStream = stream;
v.srcObject = stream; await v.play();
const det = new BarcodeDetector({ formats: ['qr_code', 'code_128', 'ean_13'] });
const loop = async () => {
if (!S.scanStream) return;
try {
const codes = await det.detect(v);
if (codes && codes.length) {
S.bindSn = codes[0].rawValue.trim();
S.scanStream.getTracks().forEach(t => t.stop()); S.scanStream = null;
bindSwitchTab('manual'); HB.toast('扫到了:' + S.bindSn);
return;
}
} catch (e) { /* 单帧失败继续 */ }
setTimeout(loop, 350);
};
loop(); HB.toast('摄像头已开,把二维码对准框');
} catch (e) {
HB.toast('摄像头没打开(' + e.message + '),用示例编号或手填', 'err');
}
}
async function submitBind() {
const sn = document.getElementById('bdSn').value.trim();
if (!sn) return HB.toast('先填设备编号', 'err');
const elder = document.getElementById('bdElder').value;
try {
await HB.post('/api/v1/devices/bind-by-sn', {
sensorId: sn, householdId: S.hh.id,
elderId: elder ? Number(elder) : null,
typeCode: null, // 类型以库里录入的为准,前端不传、也不能改
displayName: document.getElementById('bdName').value.trim(),
installLocation: document.getElementById('bdLoc').value.trim(),
zoneNo: null,
});
S.bindSn = null;
closeSheet(); HB.toast('绑定成功'); refresh();
} catch (e) { HB.toast(e.message, 'err'); }
}
/* =====================================================================
④ 老人信息 / 紧急联系人
===================================================================== */
function elderFormHtml(e) {
e = e || {};
const lv = e.care_level || 'SELF';
return '<div class="chLbl">姓名 *</div>'
+ '<input class="chInp" id="edName" value="' + HB.esc(e.name || '') + '" placeholder="如:张桂芳">'
+ '<div class="chRow">'
+ '<select class="chInp" id="edGender" style="flex:1">'
+ ['F|女', 'M|男'].map(o => '<option value="' + o.split('|')[0] + '"'
+ (e.gender === o.split('|')[0] ? ' selected' : '') + '>' + o.split('|')[1] + '</option>').join('')
+ '</select>'
+ '<input class="chInp" id="edBirth" style="flex:1" type="date" value="' + HB.esc(e.birth_date || '') + '"></div>'
+ '<div class="chLbl">照护等级</div>'
+ '<select class="chInp" id="edCare">'
+ ['SELF|自理', 'HALF|半自理', 'FULL|不能自理'].map(o =>
'<option value="' + o.split('|')[0] + '"' + (lv === o.split('|')[0] ? ' selected' : '') + '>'
+ o.split('|')[1] + '</option>').join('')
+ '</select>'
+ '<div class="chRow">'
+ '<select class="chInp" id="edAlone" style="flex:1">'
+ '<option value="1"' + (e.live_alone ? ' selected' : '') + '>独居</option>'
+ '<option value="0"' + (!e.live_alone ? ' selected' : '') + '>非独居</option></select>'
+ '<input class="chInp" id="edMobile" style="flex:1" value="" placeholder="手机号(选填)"></div>'
+ '<div class="chLbl">备注</div>'
+ '<input class="chInp" id="edRemark" value="' + HB.esc(e.remark || '') + '" placeholder="如:有高血压,夜间需留意">'
+ '<label style="font-size:12.5px;color:#6b7784;display:flex;gap:7px;align-items:center;margin:2px 0 12px">'
+ '<input type="checkbox" id="edAuth" checked> 授权建立生活痕迹画像(可拒,拒后走固定阈值)</label>';
}
function openElderForm() {
if (!S.hh) return HB.toast('先创建一个家', 'err');
showSheet('<div class="sheet-h"><h3>填写老人信息</h3><button onclick="closeSheet()">✕</button></div>'
+ '<div style="font-size:12px;color:#6b7784;margin-bottom:11px">姓名手机号会加密存,'
+ '列表里只显示打码后的号码。</div>'
+ elderFormHtml()
+ '<button class="chBtn" onclick="submitElder()">保存老人信息</button>');
}
async function submitElder() {
const name = document.getElementById('edName').value.trim();
if (!name) return HB.toast('姓名要填', 'err');
try {
const d = await HB.post('/api/v1/elders', {
householdId: S.hh.id, name: name,
gender: document.getElementById('edGender').value,
birthDate: document.getElementById('edBirth').value || null,
careLevel: document.getElementById('edCare').value,
liveAlone: document.getElementById('edAlone').value === '1',
mobile: document.getElementById('edMobile').value.trim() || null,
profileAuthorized: document.getElementById('edAuth').checked,
remark: document.getElementById('edRemark').value.trim() || null,
});
closeSheet(); HB.toast('老人信息已保存'); refresh();
if (d && d.id) setTimeout(() => openElderDetail(d.id), 350);
} catch (e) { HB.toast(e.message, 'err'); }
}
async function openElderDetail(id) {
try {
const e = await HB.get('/api/v1/elders/' + id);
let html = '<div class="sheet-h"><h3>' + HB.esc(e.name || '老人详情')
+ '</h3><button onclick="closeSheet()">✕</button></div>'
+ '<div style="font-size:12px;color:#6b7784;margin-bottom:10px">'
+ '关联设备 ' + (e.deviceCount || 0) + ' 台 · 修改后立即生效</div>'
+ elderFormHtml(e)
+ '<button class="chBtn" onclick="saveElder(' + id + ')">保存修改</button>'
+ (e.status === 'ACTIVE'
? '<button class="chBtn line" style="color:#b87a1f;border-color:#f0dcc0" '
+ 'onclick="setElderStatus(' + id + ',\'INACTIVE\')">停用(不再计入看护)</button>'
: '<button class="chBtn line" style="color:#2f9e68" '
+ 'onclick="setElderStatus(' + id + ',\'ACTIVE\')">恢复启用</button>')
+ '<div class="card-t" style="margin-top:18px"><b>紧急联系人</b>'
+ '<span style="color:#146b7a;cursor:pointer;font-size:12.5px;font-weight:600" '
+ 'onclick="openAddContact(' + id + ')">+ 添加</span></div>';
const cs = e.contacts || [];
html += cs.length ? cs.map(c =>
'<div class="dev"><div class="ic">👤</div><div class="info">'
+ '<div class="nm">' + HB.esc(c.name) + ' <span class="chip">' + HB.esc(c.relation || '联系人') + '</span></div>'
+ '<div class="sub">' + HB.esc(c.mobileMasked || '') + '</div></div>'
+ '</div>').join('')
: '<div class="empty" style="padding:14px 0">还没有紧急联系人</div>';
showSheet(html);
} catch (e2) { HB.toast(e2.message, 'err'); }
}
async function saveElder(id) { try {
await HB.put('/api/v1/elders/' + id, {
name: document.getElementById('edName').value.trim(),
gender: document.getElementById('edGender').value,
birthDate: document.getElementById('edBirth').value || null,
careLevel: document.getElementById('edCare').value,
liveAlone: document.getElementById('edAlone').value === '1',
remark: document.getElementById('edRemark').value.trim() || null,
});
HB.toast('已保存'); refresh(); openElderDetail(id);
} catch (e) { HB.toast(e.message, 'err'); }
}
async function setElderStatus(id, st) {
const word = st === 'INACTIVE' ? '停用' : '恢复';
if (!window.confirm('确定' + word + '这位老人吗?历史记录会保留,只是不再计入在看护。')) return;
try {
await HB.post('/api/v1/elders/' + id + '/status?value=' + st);
HB.toast('已' + word); refresh(); openElderDetail(id);
} catch (e) { HB.toast(e.message, 'err'); }
}
function openAddContact(elderId) {
showSheet('<div class="sheet-h"><h3>添加紧急联系人</h3><button onclick="closeSheet()">✕</button></div>'
+ '<div class="chLbl">姓名 *</div><input class="chInp" id="ctName" placeholder="如:张女士">'
+ '<div class="chLbl">手机号</div><input class="chInp" id="ctMobile" inputmode="tel" placeholder="11 位手机号">'
+ '<div class="chRow">'
+ '<input class="chInp" id="ctRelation" style="flex:1" placeholder="关系,如 女儿">'
+ '<input class="chInp" id="ctOrder" style="flex:0 0 90px" inputmode="numeric" value="1" placeholder="顺序"></div>'
+ '<button class="chBtn" onclick="submitContact(' + elderId + ')">保存联系人</button>'
+ '<button class="chBtn line" onclick="openElderDetail(' + elderId + ')">返回老人详情</button>');
}
async function submitContact(elderId) {
const name = document.getElementById('ctName').value.trim();
const mobile = document.getElementById('ctMobile').value.trim();
if (!name) return HB.toast('姓名要填', 'err');
if (mobile && !/^1\d{10}$/.test(mobile)) return HB.toast('手机号不对', 'err');
try {
await HB.post('/api/v1/elders/' + elderId + '/contacts', {
name, mobile: mobile || null,
relation: document.getElementById('ctRelation').value.trim() || '家属',
order: parseInt(document.getElementById('ctOrder').value, 10) || 1,
});
HB.toast('联系人已加'); refresh(); openElderDetail(elderId);
} catch (e) { HB.toast(e.message, 'err'); }
}
async function openHouseContacts() {
callFamily();
}
async function callFamily() {
if (!S.elders.length) return HB.toast('先填写老人信息', 'err');
let html = '<div class="sheet-h"><h3>紧急联系人</h3><button onclick="closeSheet()">✕</button></div>';
for (const e of S.elders) {
const cs = await HB.get('/api/v1/elders/' + e.id + '/contacts').catch(() => []);
html += '<div class="card-t" style="margin-top:10px"><b>' + HB.esc(e.name || '老人') + '</b>'
+ '<span style="color:#146b7a;cursor:pointer;font-size:12.5px;font-weight:600" '
+ 'onclick="openAddContact(' + e.id + ')">+ 添加</span></div>';
html += cs.length ? cs.map(c =>
'<div class="dev"><div class="ic">👤</div><div class="info">'
+ '<div class="nm">' + HB.esc(c.name) + ' <span class="chip">' + HB.esc(c.relation || '') + '</span></div>'
+ '<div class="sub">' + HB.esc(c.mobileMasked || '') + '</div></div>'
+ '</div>').join('')
: '<div style="font-size:12.5px;color:#9aa6b2;padding:6px 0">暂无</div>';
}
showSheet(html);
}
/* =====================================================================
⑤ 邀请 / 成员 / 审批 / 申请加入
===================================================================== */
async function openInvite() {
if (!S.hh) return HB.toast('先创建一个家', 'err');
try {
let info = await HB.get('/api/v1/households/' + S.hh.id + '/invite-info');
let code = info.inviteCode;
if (!code) {
const r = await HB.post('/api/v1/households/' + S.hh.id + '/invite');
code = r.inviteCode;
info = { expireAt: '' };
}
showSheet('<div class="sheet-h"><h3>邀请家人加入</h3><button onclick="closeSheet()">✕</button></div>'
+ '<div style="text-align:center;padding:12px 0 18px">'
+ '<div style="font-size:12px;color:#6b7784">把这 6 位码发给家人</div>'
+ '<div style="font-size:34px;letter-spacing:8px;font-weight:700;color:#146b7a;'
+ 'font-family:Consolas,monospace;margin:10px 0" id="invCode">' + HB.esc(code) + '</div>'
+ '<div style="font-size:11.5px;color:#8a949e">24 小时内有效 · 重新生成会让旧码作废</div></div>'
+ '<div class="pickBox">家人在家属端点「我的 → 加入别人的家」,输入这个码就能进来;'
+ '也可以按小区 + 房号申请,由户主审批。</div>'
+ '<button class="chBtn" onclick="copyInvite()">复制邀请码</button>'
+ '<button class="chBtn line" onclick="newInviteCode()">换一个码</button>');
} catch (e) { HB.toast(e.message, 'err'); }
}
async function copyInvite() {
const code = document.getElementById('invCode') ? document.getElementById('invCode').textContent : '';
try { await navigator.clipboard.writeText(code); HB.toast('已复制 ' + code); }
catch (e) { HB.toast('邀请码:' + code + '(手动记一下)'); }
}
async function newInviteCode() {
try {
const r = await HB.post('/api/v1/households/' + S.hh.id + '/invite');
document.getElementById('invCode').textContent = r.inviteCode;
HB.toast('旧码已作废');
} catch (e) { HB.toast(e.message, 'err'); }
}
async function openMembers() {
try {
const list = await HB.get('/api/v1/households/' + S.hh.id + '/members');
let html = '<div class="sheet-h"><h3>家里的人</h3><button onclick="closeSheet()">✕</button></div>';
html += list.map(m => '<div class="dev"><div class="ic">'
+ (m.is_owner ? '👑' : m.status === 'PENDING' ? '⏳' : '👤') + '</div>'
+ '<div class="info"><div class="nm">' + HB.esc(m.nickname || ('用户 ' + m.wx_user_id))
+ (m.is_owner ? ' <span class="chip">户主</span>' : '') + '</div>'
+ '<div class="sub">' + (m.status === 'PENDING' ? '待审批' : '已加入')
+ ' · ' + HB.ago(m.joined_at || m.created_at) + '</div></div></div>').join('');
html += '<div class="acts"><button class="pri" onclick="openInvite()">邀请家人</button>'
+ '<button onclick="openApplications()">审批申请</button></div>';
showSheet(html);
} catch (e) { HB.toast(e.message, 'err'); }
}
async function openApplications() {
try {
const list = await HB.get('/api/v1/households/' + S.hh.id + '/applications');
let html = '<div class="sheet-h"><h3>待审批的加入申请</h3><button onclick="closeSheet()">✕</button></div>';
if (!list.length) {
html += '<div class="empty"><span class="ic">📭</span>暂时没有申请<br>'
+ '<span style="font-size:12px">别人按地址申请加入时会出现在这里</span></div>';
} else {
html += list.map(a =>
'<div class="card" style="margin-bottom:9px"><div style="font-size:14px;font-weight:600">'
+ HB.esc(a.nickname || ('用户 ' + a.wx_user_id)) + '</div>'
+ '<div style="font-size:12px;color:#6b7784;margin:3px 0 9px">申请时间 ' + HB.ago(a.created_at) + '</div>'
+ '<div class="acts" style="margin:0">'
+ '<button class="pri" onclick="reviewApply(' + a.wx_user_id + ',true)">通过</button>'
+ '<button class="dan" onclick="reviewApply(' + a.wx_user_id + ',false)">拒绝</button>'
+ '</div></div>').join('');
}
showSheet(html);
} catch (e) { HB.toast(e.message, 'err'); }
}
async function reviewApply(uid, ok) {
try {
await HB.post('/api/v1/households/' + S.hh.id + '/applications/' + uid + '/' + (ok ? 'approve' : 'reject'));
HB.toast(ok ? '已通过,对方现在能看到了' : '已拒绝'); refresh(); openApplications();
} catch (e) { HB.toast(e.message, 'err'); }
}
function openJoin() {
showSheet('<div class="sheet-h"><h3>加入别人的家</h3><button onclick="closeSheet()">✕</button></div>'
+ '<div class="chLbl">方式一:输入家人给的 6 位邀请码</div>'
+ '<div class="chRow"><input class="chInp" id="jnCode" placeholder="如 8KQ2MP" maxlength="8" '
+ 'style="text-transform:uppercase;letter-spacing:3px">'
+ '<button class="chBtn" style="width:88px;flex:0 0 88px;margin:0 0 9px" onclick="doJoinByCode()">加入</button></div>'
+ '<div style="font-size:11.5px;color:#8a949e;margin:6px 0 16px">用邀请码进来不用等审批。</div>'
+ '<div style="border-top:1px solid var(--line);padding-top:14px">'
+ '<div class="chLbl">方式二:按小区 / 房号申请(要户主审批)</div>'
+ '<div class="chRow"><input class="chInp" id="jnKey" placeholder="如:幸福里 或 1-1-101">'
+ '<button class="chBtn line" style="width:88px;flex:0 0 88px;margin:0 0 9px" onclick="doSearchHouse()">搜索</button></div>'
+ '<div id="jnResults"></div></div>');
}
async function doJoinByCode() {
const code = document.getElementById('jnCode').value.trim();
if (!code) return HB.toast('先填邀请码', 'err');
try {
const d = await HB.post('/api/v1/households/join', { code });
localStorage.setItem('hb_family_hh', String(d.id));
closeSheet(); HB.toast('加入成功'); refresh();
} catch (e) { HB.toast(e.message, 'err'); }
}
async function doSearchHouse() {
const k = document.getElementById('jnKey').value.trim();
if (k.length < 2) return HB.toast('至少输两个字', 'err');
const box = document.getElementById('jnResults');
box.innerHTML = '<div class="sk" style="height:36px;margin:8px 0"></div>';
try {
const list = await HB.get('/api/v1/households/search?keyword=' + encodeURIComponent(k));
box.innerHTML = list.length ? list.map(h =>
'<div class="dev"><div class="ic">🏠</div><div class="info">'
+ '<div class="nm">' + HB.esc(h.community_name || '小区') + ' ' + HB.esc(h.room_no || '') + '</div>'
+ '<div class="sub">' + HB.esc([h.building_name, h.unit_name].filter(Boolean).join(' ') || h.address_text || '') + '</div></div>'
+ '<button class="btn-mini" style="background:#146b7a;color:#fff" onclick="doApplyHouse(' + h.id + ')">申请</button>'
+ '</div>').join('')
: '<div class="empty" style="padding:16px 0">没搜到,换个关键词或问户主要邀请码</div>';
} catch (e) { box.innerHTML = '<div style="font-size:12.5px;color:#c0392b;padding:8px 0">' + HB.esc(e.message) + '</div>'; }
}
async function doApplyHouse(hid) {
try {
await HB.post('/api/v1/households/apply', { householdId: hid, relation: '家属', remark: 'H5 演示申请' });
HB.toast('申请已提交,等户主通过'); closeSheet();
} catch (e) { HB.toast(e.message, 'err'); }
}
</script>
</body>
</html>