Przeglądaj źródła

新增手机H5页面(mobile.html): 视频预览+报警历史

- 手机适配: viewport/触屏优化/添加到主屏幕
- 视频: 通道切换, MJPEG实时预览
- 报警: 围栏+姿态合并列表, 类型/通道/时间筛选, 图片详情弹窗
- 10秒自动刷新报警
- 路由 /mobile.html (页面内自带登录, 不强制跳转)
- 局域网测试通过: 登录/6路视频/报警历史均正常
dev 6 dni temu
rodzic
commit
1c79f47596
2 zmienionych plików z 261 dodań i 0 usunięć
  1. 12 0
      src/web_server.cpp
  2. 249 0
      web/mobile.html

+ 12 - 0
src/web_server.cpp

@@ -181,6 +181,18 @@ public:
             res.set_content(content, "image/png");
         });
 
+        svr.Get("/mobile.html", [this](const httplib::Request& req, httplib::Response& res) {
+            // 手机H5页面: 不强制登录(页面内自带登录), 便于手机APP直达
+            ifstream f("web/mobile.html");
+            if (f.is_open()) {
+                string content((istreambuf_iterator<char>(f)), istreambuf_iterator<char>());
+                res.set_content(content, "text/html");
+            } else {
+                res.status = 404;
+                res.set_content("mobile.html not found", "text/plain");
+            }
+        });
+
         svr.Get("/preview.html", [this](const httplib::Request& req, httplib::Response& res) {
             if (!authed(req)) { res.status = 302; res.set_header("Location", "/login"); return; }
             ifstream f("web/preview.html");

+ 249 - 0
web/mobile.html

@@ -0,0 +1,249 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+<meta name="apple-mobile-web-app-capable" content="yes">
+<title>钢包视频监测</title>
+<link rel="icon" href="/logo.png" type="image/png">
+<style>
+*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
+body{font-family:system-ui,sans-serif;background:#0b1220;color:#e8e8e8;height:100vh;overflow:hidden}
+.hidden{display:none!important}
+
+/* 登录 */
+#loginPage{display:flex;flex-direction:column;justify-content:center;align-items:center;height:100vh;padding:24px;background:#0b1220}
+#loginPage .logo{width:64px;height:64px;margin-bottom:16px}
+#loginPage h1{font-size:20px;margin-bottom:24px;color:#e94560}
+#loginPage input{width:100%;max-width:320px;padding:12px 14px;margin-bottom:12px;border:1px solid #0f3460;background:#0e1426;color:#eee;font-size:16px;border-radius:6px}
+#loginPage button{width:100%;max-width:320px;padding:12px;background:#e94560;color:#fff;border:none;font-size:16px;border-radius:6px;cursor:pointer}
+#loginErr{color:#e94560;font-size:13px;margin-bottom:12px;min-height:18px}
+
+/* 主界面 */
+#appPage{display:flex;flex-direction:column;height:100vh}
+.topbar{display:flex;align-items:center;padding:10px 12px;background:#0e1426;border-bottom:1px solid #0f3460;flex-shrink:0}
+.topbar h2{font-size:16px;flex:1;color:#eee}
+.topbar .tab{display:flex;gap:4px}
+.topbar .tab button{padding:6px 12px;background:transparent;border:none;color:#8892a8;font-size:14px;cursor:pointer;border-radius:4px}
+.topbar .tab button.active{background:#182242;color:#e94560;font-weight:bold}
+
+/* 视频页 */
+#videoPage{flex:1;display:flex;flex-direction:column;overflow:hidden}
+#videoWrap{flex:1;position:relative;background:#000;min-height:0}
+#videoWrap video{width:100%;height:100%;object-fit:contain}
+#videoLoader{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#888}
+.chSwitcher{display:flex;overflow-x:auto;padding:8px;background:#0e1426;border-top:1px solid #0f3460;flex-shrink:0}
+.chSwitcher button{padding:8px 14px;margin-right:8px;background:#0f3460;border:none;color:#eee;border-radius:4px;font-size:13px;white-space:nowrap;cursor:pointer}
+.chSwitcher button.active{background:#e94560}
+
+/* 报警页 */
+#alarmPage{flex:1;overflow-y:auto;padding:12px}
+.filterBar{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap}
+.filterBar select,.filterBar input{padding:8px;background:#0e1426;border:1px solid #0f3460;color:#eee;font-size:13px;border-radius:4px}
+.filterBar select{flex:1;min-width:100px}
+.filterBar input[type=datetime-local]{flex:1;min-width:130px}
+.alarmItem{display:flex;gap:10px;padding:10px;margin-bottom:10px;background:#0e1426;border:1px solid #0f3460;border-left:4px solid #e94560;border-radius:6px}
+.alarmItem img{width:72px;height:54px;object-fit:cover;border-radius:4px;flex-shrink:0}
+.alarmItem .info{flex:1;min-width:0}
+.alarmItem .time{font-size:11px;color:#888;margin-bottom:4px}
+.alarmItem .type{font-size:14px;font-weight:bold;margin-bottom:2px}
+.alarmItem .meta{font-size:12px;color:#aaa}
+.alarmItem .conf{font-size:12px;color:#27ae60}
+
+/* 详情弹窗 */
+.modal{position:fixed;inset:0;background:rgba(0,0,0,.9);z-index:50;display:none;flex-direction:column}
+.modal.show{display:flex}
+.modal img{flex:1;object-fit:contain;min-height:0}
+.modal .mbar{display:flex;align-items:center;padding:10px 14px;background:#0e1426;border-top:1px solid #0f3460}
+.modal .mbar .minfo{flex:1;font-size:13px}
+.modal .mbar button{padding:8px 16px;background:#e94560;border:none;color:#fff;border-radius:4px;font-size:14px}
+
+.toast{position:fixed;top:20px;left:50%;transform:translateX(-50%);background:rgba(233,69,96,.95);color:#fff;padding:10px 18px;border-radius:6px;font-size:14px;z-index:99;display:none}
+.empty-tip{text-align:center;color:#666;padding:40px 0;font-size:14px}
+</style>
+</head>
+<body>
+
+<div id="loginPage">
+  <img class="logo" src="/logo.png">
+  <h1>钢包视频监测</h1>
+  <div id="loginErr"></div>
+  <input type="text" id="loginUser" placeholder="用户名" value="admin">
+  <input type="password" id="loginPass" placeholder="密码" value="admin">
+  <button onclick="doLogin()">登 录</button>
+</div>
+
+<div id="appPage" class="hidden">
+  <div class="topbar">
+    <h2 id="pageTitle">视频监控</h2>
+    <div class="tab">
+      <button id="tabVideo" class="active" onclick="switchTab('video')">视频</button>
+      <button id="tabAlarm" onclick="switchTab('alarm')">报警</button>
+    </div>
+  </div>
+
+  <div id="videoPage">
+    <div id="videoWrap">
+      <video id="video" autoplay muted playsinline></video>
+      <div id="videoLoader">加载中...</div>
+    </div>
+    <div class="chSwitcher" id="chSwitcher"></div>
+  </div>
+
+  <div id="alarmPage" class="hidden">
+    <div class="filterBar">
+      <select id="fltType" onchange="loadAlarms()">
+        <option value="">全部类型</option>
+        <option value="fence">围栏报警</option>
+        <option value="pose">姿态报警</option>
+      </select>
+      <select id="fltCh" onchange="loadAlarms()"><option value="">全部通道</option></select>
+      <input type="datetime-local" id="fltStart">
+      <input type="datetime-local" id="fltEnd">
+      <button onclick="loadAlarms()" style="padding:8px 12px;background:#e94560;border:none;color:#fff;border-radius:4px">查询</button>
+    </div>
+    <div id="alarmList"></div>
+  </div>
+</div>
+
+<div class="modal" id="detailModal">
+  <img id="detailImg" alt="">
+  <div class="mbar">
+    <div class="minfo" id="detailInfo"></div>
+    <button onclick="closeDetail()">关闭</button>
+  </div>
+</div>
+
+<div class="toast" id="toast"></div>
+
+<script>
+let channels = [];
+let curChannel = 0;
+let curType = '';
+
+function toast(msg) {
+  const t = document.getElementById('toast');
+  t.textContent = msg;
+  t.style.display = 'block';
+  setTimeout(() => t.style.display = 'none', 2500);
+}
+
+async function doLogin() {
+  const u = document.getElementById('loginUser').value;
+  const p = document.getElementById('loginPass').value;
+  try {
+    const r = await fetch('/api/login', {
+      method: 'POST',
+      headers: {'Content-Type': 'application/json'},
+      body: JSON.stringify({username: u, password: p})
+    });
+    const d = await r.json();
+    if (d.ok) {
+      document.getElementById('loginPage').classList.add('hidden');
+      document.getElementById('appPage').classList.remove('hidden');
+      init();
+    } else {
+      document.getElementById('loginErr').textContent = d.error || '登录失败';
+    }
+  } catch (e) {
+    document.getElementById('loginErr').textContent = '网络错误';
+  }
+}
+
+async function init() {
+  await loadChannels();
+  renderChSwitcher();
+  switchChannel(0);
+}
+
+async function loadChannels() {
+  try {
+    const r = await fetch('/api/status');
+    if (r.status === 401) { location.reload(); return; }
+    const d = await r.json();
+    channels = d.channels || [];
+    const sel = document.getElementById('fltCh');
+    sel.innerHTML = '<option value="">全部通道</option>' +
+      channels.map(c => `<option value="${c.id}">CH${c.id} ${c.name||''}</option>`).join('');
+  } catch (e) {}
+}
+
+function renderChSwitcher() {
+  const sw = document.getElementById('chSwitcher');
+  sw.innerHTML = channels.map(c =>
+    `<button data-ch="${c.id}" class="${c.id===curChannel?'active':''}" onclick="switchChannel(${c.id})">CH${c.id} ${c.name||''}</button>`).join('');
+}
+
+function switchChannel(id) {
+  curChannel = id;
+  renderChSwitcher();
+  const v = document.getElementById('video');
+  v.src = '/mjpeg/' + id + '?t=' + Date.now();
+  document.getElementById('videoLoader').style.display = 'block';
+  v.onloadstart = () => {};
+  v.onplaying = () => document.getElementById('videoLoader').style.display = 'none';
+  v.onerror = () => { document.getElementById('videoLoader').textContent = '视频加载失败'; };
+}
+
+function switchTab(tab) {
+  document.getElementById('tabVideo').classList.toggle('active', tab==='video');
+  document.getElementById('tabAlarm').classList.toggle('active', tab==='alarm');
+  document.getElementById('videoPage').classList.toggle('hidden', tab!=='video');
+  document.getElementById('alarmPage').classList.toggle('hidden', tab!=='alarm');
+  if (tab === 'alarm') loadAlarms();
+}
+
+function poseColor(a) {
+  const m = {'跌倒':'#e94560','倒地':'#e94560','举手呼救':'#f39c12','异常蹲坐':'#3498db','半躺':'#9b59b6','手近嘴部':'#e67e22','疲劳瞌睡':'#8e44ad','长时间静止':'#2c3e50','疑似离岗':'#c0392b'};
+  return m[a] || '#8892a8';
+}
+
+async function loadAlarms() {
+  const type = document.getElementById('fltType').value;
+  const ch = document.getElementById('fltCh').value;
+  const st = document.getElementById('fltStart').value.replace('T',' ');
+  const en = document.getElementById('fltEnd').value.replace('T',' ');
+  const qs = new URLSearchParams();
+  if (ch) qs.set('channel', ch);
+  if (st) qs.set('start', st + ':00');
+  if (en) qs.set('end', en + ':59');
+  try {
+    const [fr, pr] = await Promise.all([
+      fetch('/api/alarms' + (qs ? '?' + qs : '')),
+      fetch('/api/pose/alarms' + (qs ? '?' + qs : ''))
+    ]);
+    const fd = await fr.json(), pd = await pr.json();
+    const fence = (fd.items||[]).map(a => ({t:'围栏', ts:a.timestamp, ch:a.channel, label:a.fence_name, detail:a.class_name, conf:a.confidence, img:a.screenshot, color:'#27ae60'}));
+    const pose = (pd.items||[]).map(a => ({t:a.action, ts:a.timestamp, ch:a.channel, label:a.action, detail:'姿态', conf:a.score, img:a.screenshot, color:poseColor(a.action)}));
+    let items = type==='fence' ? fence : type==='pose' ? pose : [...fence, ...pose];
+    items.sort((a,b) => b.ts.localeCompare(a.ts));
+    const list = document.getElementById('alarmList');
+    if (!items.length) { list.innerHTML = '<div class="empty-tip">暂无报警记录</div>'; return; }
+    list.innerHTML = items.slice(0,100).map(a => `
+      <div class="alarmItem" style="border-left-color:${a.color}" onclick="showDetail('${a.img}','${a.ts}','${a.label}','${a.detail}',${a.conf},${a.ch})">
+        <img src="/${a.img}" loading="lazy">
+        <div class="info">
+          <div class="time">${a.ts} · CH${a.ch}</div>
+          <div class="type" style="color:${a.color}">${a.label}</div>
+          <div class="meta">${a.detail}</div>
+          <div class="conf">${(a.conf*100).toFixed(0)}%</div>
+        </div>
+      </div>`).join('');
+  } catch (e) {}
+}
+
+function showDetail(img, ts, label, detail, conf, ch) {
+  document.getElementById('detailImg').src = '/' + img;
+  document.getElementById('detailInfo').innerHTML =
+    `<b>${label}</b> ${(conf*100).toFixed(0)}%<br>时间: ${ts}<br>通道: CH${ch}<br>详情: ${detail}`;
+  document.getElementById('detailModal').classList.add('show');
+}
+function closeDetail() { document.getElementById('detailModal').classList.remove('show'); }
+
+// 每10秒刷新报警
+setInterval(() => {
+  if (!document.getElementById('alarmPage').classList.contains('hidden')) loadAlarms();
+}, 10000);
+</script>
+</body>
+</html>