mobile.html 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  6. <meta name="apple-mobile-web-app-capable" content="yes">
  7. <title>钢包视频监测</title>
  8. <link rel="icon" href="/logo.png" type="image/png">
  9. <style>
  10. *{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
  11. body{font-family:system-ui,sans-serif;background:#0b1220;color:#e8e8e8;height:100vh;overflow:hidden}
  12. .hidden{display:none!important}
  13. /* 登录 */
  14. #loginPage{display:flex;flex-direction:column;justify-content:center;align-items:center;height:100vh;padding:24px;background:#0b1220}
  15. #loginPage .logo{width:64px;height:64px;margin-bottom:16px}
  16. #loginPage h1{font-size:20px;margin-bottom:24px;color:#e94560}
  17. #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}
  18. #loginPage button{width:100%;max-width:320px;padding:12px;background:#e94560;color:#fff;border:none;font-size:16px;border-radius:6px;cursor:pointer}
  19. #loginErr{color:#e94560;font-size:13px;margin-bottom:12px;min-height:18px}
  20. /* 主界面 */
  21. #appPage{display:flex;flex-direction:column;height:100vh}
  22. .topbar{display:flex;align-items:center;padding:10px 12px;background:#0e1426;border-bottom:1px solid #0f3460;flex-shrink:0}
  23. .topbar h2{font-size:16px;flex:1;color:#eee}
  24. .topbar .tab{display:flex;gap:4px}
  25. .topbar .tab button{padding:6px 12px;background:transparent;border:none;color:#8892a8;font-size:14px;cursor:pointer;border-radius:4px}
  26. .topbar .tab button.active{background:#182242;color:#e94560;font-weight:bold}
  27. /* 视频页 */
  28. #videoPage{flex:1;display:flex;flex-direction:column;overflow:hidden}
  29. #videoWrap{flex:1;position:relative;background:#000;min-height:0}
  30. #videoWrap img{width:100%;height:100%;object-fit:contain;display:block}
  31. #videoLoader{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#888}
  32. .chSwitcher{display:flex;overflow-x:auto;padding:8px;background:#0e1426;border-top:1px solid #0f3460;flex-shrink:0}
  33. .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}
  34. .chSwitcher button.active{background:#e94560}
  35. /* 报警页 */
  36. #alarmPage{flex:1;overflow-y:auto;padding:12px}
  37. .filterBar{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap}
  38. .filterBar select,.filterBar input{padding:8px;background:#0e1426;border:1px solid #0f3460;color:#eee;font-size:13px;border-radius:4px}
  39. .filterBar select{flex:1;min-width:100px}
  40. .filterBar input[type=datetime-local]{flex:1;min-width:130px}
  41. .alarmItem{display:flex;gap:10px;padding:10px;margin-bottom:10px;background:#0e1426;border:1px solid #0f3460;border-left:4px solid #e94560;border-radius:6px}
  42. .alarmItem img{width:72px;height:54px;object-fit:cover;border-radius:4px;flex-shrink:0}
  43. .alarmItem .info{flex:1;min-width:0}
  44. .alarmItem .time{font-size:11px;color:#888;margin-bottom:4px}
  45. .alarmItem .type{font-size:14px;font-weight:bold;margin-bottom:2px}
  46. .alarmItem .meta{font-size:12px;color:#aaa}
  47. .alarmItem .conf{font-size:12px;color:#27ae60}
  48. /* 详情弹窗 */
  49. .modal{position:fixed;inset:0;background:rgba(0,0,0,.9);z-index:50;display:none;flex-direction:column}
  50. .modal.show{display:flex}
  51. .modal img{flex:1;object-fit:contain;min-height:0}
  52. .modal .mbar{display:flex;align-items:center;padding:10px 14px;background:#0e1426;border-top:1px solid #0f3460}
  53. .modal .mbar .minfo{flex:1;font-size:13px}
  54. .modal .mbar button{padding:8px 16px;background:#e94560;border:none;color:#fff;border-radius:4px;font-size:14px}
  55. .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}
  56. .empty-tip{text-align:center;color:#666;padding:40px 0;font-size:14px}
  57. </style>
  58. </head>
  59. <body>
  60. <div id="loginPage">
  61. <img class="logo" src="/logo.png">
  62. <h1>钢包视频监测</h1>
  63. <div id="loginErr"></div>
  64. <input type="text" id="loginUser" placeholder="用户名" value="admin">
  65. <input type="password" id="loginPass" placeholder="密码" value="admin">
  66. <button onclick="doLogin()">登 录</button>
  67. </div>
  68. <div id="appPage" class="hidden">
  69. <div class="topbar">
  70. <h2 id="pageTitle">视频监控</h2>
  71. <div class="tab">
  72. <button id="tabVideo" class="active" onclick="switchTab('video')">视频</button>
  73. <button id="tabAlarm" onclick="switchTab('alarm')">报警</button>
  74. </div>
  75. </div>
  76. <div id="videoPage">
  77. <div id="videoWrap">
  78. <img id="video" alt="">
  79. <div id="videoLoader">加载中...</div>
  80. </div>
  81. <div class="chSwitcher" id="chSwitcher"></div>
  82. </div>
  83. <div id="alarmPage" class="hidden">
  84. <div class="filterBar">
  85. <select id="fltType" onchange="loadAlarms()">
  86. <option value="">全部类型</option>
  87. <option value="fence">围栏报警</option>
  88. <option value="pose">姿态报警</option>
  89. </select>
  90. <select id="fltCh" onchange="loadAlarms()"><option value="">全部通道</option></select>
  91. <input type="datetime-local" id="fltStart">
  92. <input type="datetime-local" id="fltEnd">
  93. <button onclick="loadAlarms()" style="padding:8px 12px;background:#e94560;border:none;color:#fff;border-radius:4px">查询</button>
  94. </div>
  95. <div id="alarmList"></div>
  96. </div>
  97. </div>
  98. <div class="modal" id="detailModal">
  99. <img id="detailImg" alt="">
  100. <div class="mbar">
  101. <div class="minfo" id="detailInfo"></div>
  102. <button onclick="closeDetail()">关闭</button>
  103. </div>
  104. </div>
  105. <div class="toast" id="toast"></div>
  106. <script>
  107. let channels = [];
  108. let curChannel = 0;
  109. let curType = '';
  110. let videoTimer = null;
  111. let videoBusy = false;
  112. let videoUrl = '';
  113. function toast(msg) {
  114. const t = document.getElementById('toast');
  115. t.textContent = msg;
  116. t.style.display = 'block';
  117. setTimeout(() => t.style.display = 'none', 2500);
  118. }
  119. async function doLogin() {
  120. const u = document.getElementById('loginUser').value;
  121. const p = document.getElementById('loginPass').value;
  122. try {
  123. const r = await fetch('/api/login', {
  124. method: 'POST',
  125. headers: {'Content-Type': 'application/json'},
  126. body: JSON.stringify({username: u, password: p})
  127. });
  128. const d = await r.json();
  129. if (d.ok) {
  130. document.getElementById('loginPage').classList.add('hidden');
  131. document.getElementById('appPage').classList.remove('hidden');
  132. init();
  133. } else {
  134. document.getElementById('loginErr').textContent = d.error || '登录失败';
  135. }
  136. } catch (e) {
  137. document.getElementById('loginErr').textContent = '网络错误';
  138. }
  139. }
  140. async function init() {
  141. await loadChannels();
  142. renderChSwitcher();
  143. switchChannel(0);
  144. }
  145. async function loadChannels() {
  146. try {
  147. const r = await fetch('/api/status');
  148. if (r.status === 401) { location.reload(); return; }
  149. const d = await r.json();
  150. channels = d.channels || [];
  151. const sel = document.getElementById('fltCh');
  152. sel.innerHTML = '<option value="">全部通道</option>' +
  153. channels.map(c => `<option value="${c.id}">CH${c.id} ${c.name||''}</option>`).join('');
  154. } catch (e) {}
  155. }
  156. function renderChSwitcher() {
  157. const sw = document.getElementById('chSwitcher');
  158. sw.innerHTML = channels.map(c =>
  159. `<button data-ch="${c.id}" class="${c.id===curChannel?'active':''}" onclick="switchChannel(${c.id})">CH${c.id} ${c.name||''}</button>`).join('');
  160. }
  161. function switchChannel(id) {
  162. curChannel = id;
  163. renderChSwitcher();
  164. startVideoPolling(id);
  165. }
  166. // 手机浏览器对multipart MJPEG兼容性不一致, 使用单帧JPEG轮询保证兼容
  167. function startVideoPolling(id) {
  168. if (videoTimer) { clearTimeout(videoTimer); videoTimer = null; }
  169. const v = document.getElementById('video');
  170. videoUrl = '/stream/' + id;
  171. videoBusy = false;
  172. document.getElementById('videoLoader').textContent = '加载中...';
  173. document.getElementById('videoLoader').style.display = 'block';
  174. const next = () => {
  175. if (curChannel !== id) return;
  176. const img = new Image();
  177. videoBusy = true;
  178. img.onload = () => {
  179. if (curChannel === id) {
  180. v.src = img.src;
  181. document.getElementById('videoLoader').style.display = 'none';
  182. }
  183. videoBusy = false;
  184. videoTimer = setTimeout(next, 100);
  185. };
  186. img.onerror = () => {
  187. videoBusy = false;
  188. document.getElementById('videoLoader').textContent = '视频加载失败,重试中...';
  189. videoTimer = setTimeout(next, 1000);
  190. };
  191. img.src = videoUrl + '?t=' + Date.now();
  192. };
  193. next();
  194. }
  195. function switchTab(tab) {
  196. document.getElementById('tabVideo').classList.toggle('active', tab==='video');
  197. document.getElementById('tabAlarm').classList.toggle('active', tab==='alarm');
  198. document.getElementById('videoPage').classList.toggle('hidden', tab!=='video');
  199. document.getElementById('alarmPage').classList.toggle('hidden', tab!=='alarm');
  200. if (tab === 'alarm') loadAlarms();
  201. }
  202. function poseColor(a) {
  203. const m = {'跌倒':'#e94560','倒地':'#e94560','举手呼救':'#f39c12','异常蹲坐':'#3498db','半躺':'#9b59b6','手近嘴部':'#e67e22','疲劳瞌睡':'#8e44ad','长时间静止':'#2c3e50','疑似离岗':'#c0392b'};
  204. return m[a] || '#8892a8';
  205. }
  206. async function loadAlarms() {
  207. const type = document.getElementById('fltType').value;
  208. const ch = document.getElementById('fltCh').value;
  209. const st = document.getElementById('fltStart').value.replace('T',' ');
  210. const en = document.getElementById('fltEnd').value.replace('T',' ');
  211. const qs = new URLSearchParams();
  212. if (ch) qs.set('channel', ch);
  213. if (st) qs.set('start', st + ':00');
  214. if (en) qs.set('end', en + ':59');
  215. try {
  216. const [fr, pr] = await Promise.all([
  217. fetch('/api/alarms' + (qs ? '?' + qs : '')),
  218. fetch('/api/pose/alarms' + (qs ? '?' + qs : ''))
  219. ]);
  220. const fd = await fr.json(), pd = await pr.json();
  221. 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'}));
  222. 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)}));
  223. let items = type==='fence' ? fence : type==='pose' ? pose : [...fence, ...pose];
  224. items.sort((a,b) => b.ts.localeCompare(a.ts));
  225. const list = document.getElementById('alarmList');
  226. if (!items.length) { list.innerHTML = '<div class="empty-tip">暂无报警记录</div>'; return; }
  227. list.innerHTML = items.slice(0,100).map(a => `
  228. <div class="alarmItem" style="border-left-color:${a.color}" onclick="showDetail('${a.img}','${a.ts}','${a.label}','${a.detail}',${a.conf},${a.ch})">
  229. <img src="/${a.img}" loading="lazy">
  230. <div class="info">
  231. <div class="time">${a.ts} · CH${a.ch}</div>
  232. <div class="type" style="color:${a.color}">${a.label}</div>
  233. <div class="meta">${a.detail}</div>
  234. <div class="conf">${(a.conf*100).toFixed(0)}%</div>
  235. </div>
  236. </div>`).join('');
  237. } catch (e) {}
  238. }
  239. function showDetail(img, ts, label, detail, conf, ch) {
  240. document.getElementById('detailImg').src = '/' + img;
  241. document.getElementById('detailInfo').innerHTML =
  242. `<b>${label}</b> ${(conf*100).toFixed(0)}%<br>时间: ${ts}<br>通道: CH${ch}<br>详情: ${detail}`;
  243. document.getElementById('detailModal').classList.add('show');
  244. }
  245. function closeDetail() { document.getElementById('detailModal').classList.remove('show'); }
  246. // 每10秒刷新报警
  247. setInterval(() => {
  248. if (!document.getElementById('alarmPage').classList.contains('hidden')) loadAlarms();
  249. }, 10000);
  250. </script>
  251. </body>
  252. </html>