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