| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379 |
- <!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 img{width:100%;height:100%;object-fit:contain;display:block}
- #videoLoader{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#888}
- #bgState{display:none;position:absolute;top:8px;left:50%;transform:translateX(-50%);background:rgba(233,69,96,.9);color:#fff;font-size:12px;padding:5px 12px;border-radius:4px}
- .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}
- #alarmMore{text-align:center;color:#8892a8;font-size:13px;padding:16px;display:none}
- .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">
- <img id="video" alt="">
- <div id="videoLoader">加载中...</div>
- <div id="bgState">后台运行中(视频已暂停)</div>
- </div>
- <div class="chSwitcher" id="chSwitcher"></div>
- </div>
- <div id="alarmPage" class="hidden">
- <div class="filterBar">
- <select id="fltType" onchange="loadAlarms(true)">
- <option value="">全部类型</option>
- <option value="fence">围栏报警</option>
- <option value="pose">姿态报警</option>
- </select>
- <select id="fltCh" onchange="loadAlarms(true)"><option value="">全部通道</option></select>
- <input type="datetime-local" id="fltStart">
- <input type="datetime-local" id="fltEnd">
- <button onclick="loadAlarms(true)" style="padding:8px 12px;background:#e94560;border:none;color:#fff;border-radius:4px">查询</button>
- </div>
- <div id="alarmList"></div>
- <div id="alarmMore">下拉加载更多</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 = '';
- let alarmItems = [];
- let alarmPage = 0;
- const alarmPageSize = 20;
- let alarmLoading = false;
- let alarmHasMore = false;
- let videoTimer = null;
- let videoBusy = false;
- let videoUrl = '';
- 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);
- notifyNativeReady();
- }
- // 通知 Android 宿主 H5 已就绪(宿主可据此开始转发生命周期事件)
- function notifyNativeReady() {
- try {
- if (window.AndroidBridge && window.AndroidBridge.onWebReady) {
- window.AndroidBridge.onWebReady();
- }
- } catch (e) {}
- }
- 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();
- startVideoPolling(id);
- }
- // 手机浏览器对multipart MJPEG兼容性不一致, 使用单帧JPEG轮询保证兼容
- function startVideoPolling(id) {
- stopVideoPolling();
- const v = document.getElementById('video');
- videoUrl = '/stream/' + id;
- videoBusy = false;
- document.getElementById('videoLoader').textContent = '加载中...';
- document.getElementById('videoLoader').style.display = 'block';
- const next = () => {
- if (curChannel !== id) return;
- // 后台运行时不拉流(省流量/CPU), 回前台由 setBackground 恢复
- if (isBackground) { videoTimer = setTimeout(next, 500); return; }
- const img = new Image();
- videoBusy = true;
- img.onload = () => {
- if (curChannel === id) {
- v.src = img.src;
- document.getElementById('videoLoader').style.display = 'none';
- }
- videoBusy = false;
- videoTimer = setTimeout(next, 100);
- };
- img.onerror = () => {
- videoBusy = false;
- document.getElementById('videoLoader').textContent = '视频加载失败,重试中...';
- videoTimer = setTimeout(next, 1000);
- };
- img.src = videoUrl + '?t=' + Date.now();
- };
- next();
- }
- function stopVideoPolling() {
- if (videoTimer) { clearTimeout(videoTimer); videoTimer = null; }
- videoBusy = false;
- }
- /* ============ 后台运行检测 ============
- * 三种来源:
- * 1) Android WebView 原生回调 (最可靠, 宿主 onPause/onResume 时调用)
- * 2) Page Visibility API (浏览器/部分WebView支持)
- * 3) 心跳兜底: 定时器被系统挂起时, 恢复后检测时间跳变判定经过后台
- * ======================================= */
- let isBackground = false;
- let lastHeartbeat = Date.now();
- function setBackground(bg) {
- if (isBackground === bg) return;
- isBackground = bg;
- const badge = document.getElementById('bgState');
- if (badge) {
- badge.textContent = '后台运行中(视频已暂停)';
- badge.style.display = bg ? 'block' : 'none';
- }
- if (bg) {
- stopVideoPolling(); // 进后台: 停止拉流省流量
- } else if (!document.getElementById('videoPage').classList.contains('hidden')) {
- startVideoPolling(curChannel); // 回前台: 恢复
- }
- }
- // 1) 供 Android 原生调用: webView.evaluateJavascript("onAndroidLifecycle('pause')")
- window.onAndroidLifecycle = function(state) {
- setBackground(state === 'pause' || state === 'background' || state === 'stop');
- };
- window.onNativeBackground = function() { setBackground(true); };
- window.onNativeForeground = function() { setBackground(false); };
- // 2) 浏览器 Page Visibility API
- document.addEventListener('visibilitychange', () => {
- setBackground(document.hidden);
- });
- window.addEventListener('blur', () => { /* 仅失焦不一定是后台, 不处理 */ });
- window.addEventListener('pagehide', () => setBackground(true));
- window.addEventListener('pageshow', () => setBackground(false));
- // 3) 心跳兜底: 定时器被挂起后, 间隔异常变大说明经历过后台
- setInterval(() => {
- const now = Date.now();
- const gap = now - lastHeartbeat;
- lastHeartbeat = now;
- if (gap > 3000 && !document.hidden) {
- // 定时器被冻结超过3秒后恢复 -> 刚从后台回来
- setBackground(false);
- }
- }, 1000);
- 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') {
- stopVideoPolling(); // 看报警时暂停视频拉流, 省流量
- loadAlarms(true);
- } else if (!document.hidden) {
- startVideoPolling(curChannel); // 回到视频页恢复
- }
- }
- function poseColor(a) {
- const m = {'跌倒':'#e94560','倒地':'#e94560','举手呼救':'#f39c12','异常蹲坐':'#3498db','半躺':'#9b59b6','手近嘴部':'#e67e22','疲劳瞌睡':'#8e44ad','长时间静止':'#2c3e50','疑似离岗':'#c0392b'};
- return m[a] || '#8892a8';
- }
- function renderAlarmPage() {
- const start = alarmPage * alarmPageSize;
- const page = alarmItems.slice(start, start + alarmPageSize);
- alarmPage++;
- alarmHasMore = start + alarmPageSize < alarmItems.length;
- const list = document.getElementById('alarmList');
- list.insertAdjacentHTML('beforeend', page.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(''));
- document.getElementById('alarmMore').style.display = alarmHasMore ? 'block' : 'none';
- }
- async function loadAlarms(reset = false) {
- if (alarmLoading) return;
- if (!reset && alarmItems.length && alarmHasMore) { renderAlarmPage(); return; }
- alarmLoading = true;
- alarmItems = []; alarmPage = 0; alarmHasMore = true;
- document.getElementById('alarmList').innerHTML = '';
- 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();
- qs.set('limit', '100');
- 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), fetch('/api/pose/alarms?' + 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)}));
- alarmItems = type==='fence' ? fence : type==='pose' ? pose : [...fence, ...pose];
- alarmItems.sort((a,b) => b.ts.localeCompare(a.ts));
- if (!alarmItems.length) {
- document.getElementById('alarmList').innerHTML = '<div class="empty-tip">暂无报警记录</div>';
- document.getElementById('alarmMore').style.display = 'none';
- } else renderAlarmPage();
- } catch (e) {}
- alarmLoading = false;
- }
- 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'); }
- document.getElementById('alarmPage').addEventListener('scroll', () => {
- const el = document.getElementById('alarmPage');
- if (el.scrollTop + el.clientHeight >= el.scrollHeight - 80 && alarmHasMore && !alarmLoading) {
- loadAlarms(false);
- }
- });
- // 每10秒刷新报警列表(回到第一页)
- setInterval(() => {
- if (!document.getElementById('alarmPage').classList.contains('hidden')) loadAlarms(true);
- }, 10000);
- </script>
- </body>
- </html>
|