preview.html 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  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">
  6. <title>工位预览 - 恒达视觉监控平台</title>
  7. <link rel="icon" href="/logo.png" type="image/png">
  8. <style>
  9. *{margin:0;padding:0;box-sizing:border-box;border-radius:0 !important}
  10. body{font-family:system-ui,sans-serif;background:#12192e;color:#eee;overflow:hidden}
  11. .app{display:flex;height:100vh}
  12. .sidebar{width:200px;background:#0e1426;border-right:1px solid #0f3460;display:flex;flex-direction:column;flex-shrink:0}
  13. .logo{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid #0f3460}
  14. .logo img{height:28px;flex-shrink:0}
  15. .logo .t{font-size:14px;color:#e94560;font-weight:bold;white-space:nowrap}
  16. .nav{padding-top:8px;flex:1}
  17. .nav-item{display:flex;align-items:center;gap:12px;padding:13px 18px;color:#8892a8;cursor:pointer;font-size:14px;border-left:3px solid transparent;white-space:nowrap;user-select:none;text-decoration:none}
  18. .nav-item:hover{background:#182242;color:#ccc}
  19. .nav-item.active{color:#e94560;border-left-color:#e94560;background:#182242;font-weight:bold}
  20. .nav-item .ico{width:20px;text-align:center;flex-shrink:0;font-weight:bold}
  21. .nav-label{transition:none}
  22. .side-foot{border-top:1px solid #0f3460;padding:12px 14px;display:flex;flex-direction:column;gap:10px}
  23. .mode-btn{padding:6px 16px;border:none;cursor:pointer;font-size:13px;font-weight:bold;border-radius:4px}
  24. .mode-btn.auto{background:#27ae60;color:#fff}
  25. .mode-btn.hand{background:#e94560;color:#fff}
  26. .mode-btn:hover{opacity:0.9}
  27. .collapse-btn{background:none;border:1px solid #0f3460;color:#8892a8;padding:5px;cursor:pointer;font-size:12px;width:100%}
  28. .collapse-btn:hover{color:#e94560;border-color:#e94560}
  29. .content{flex:1;display:flex;flex-direction:column;overflow:hidden}
  30. .status-bar{display:flex;align-items:center;justify-content:space-between;padding:10px 20px;background:#0e1426;border-bottom:1px solid #0f3460}
  31. .status-left{display:flex;gap:20px;align-items:center;flex-wrap:wrap}
  32. .status-item{font-size:13px;color:#888}
  33. .status-item .val{color:#3498db;font-weight:bold}
  34. .status-item.online .val{color:#27ae60}
  35. .status-item.offline .val{color:#e74c3c}
  36. .main-body{flex:1;display:flex;overflow:hidden}
  37. .bucket-panel{width:140px;background:#0e1426;border-right:1px solid #0f3460;padding:12px;overflow-y:auto;flex-shrink:0}
  38. .bucket-panel h4{color:#e94560;font-size:13px;margin-bottom:12px}
  39. .bucket-btn{width:100%;padding:10px 6px;margin-bottom:8px;background:#141d36;border:2px solid #0f3460;border-radius:8px;cursor:pointer;text-align:center;transition:all 0.2s}
  40. .bucket-btn:hover{border-color:#3498db;background:#182242}
  41. .bucket-btn.active{border-color:#27ae60;background:#1a3a2a}
  42. .bucket-btn .name{font-size:13px;font-weight:bold;color:#eee}
  43. .bucket-btn .tag{font-size:11px;color:#888;margin-top:4px}
  44. .video-area{flex:1;display:flex;padding:12px;gap:12px}
  45. .video-cell{flex:1;background:#000;border:2px solid #0f3460;border-radius:8px;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;flex-direction:column}
  46. .video-cell img{width:100%;height:calc(100% - 32px);object-fit:contain}
  47. .video-cell .cam-label{position:absolute;top:0;left:0;right:0;height:32px;display:flex;align-items:center;justify-content:center;background:rgba(14,20,38,.9);border-bottom:1px solid #0f3460;color:#ddd;font-size:13px;font-weight:bold;z-index:2}
  48. .video-cell.offline{border-color:#e74c3c}
  49. .video-cell.offline img{display:none}
  50. .video-cell.offline::after{content:"信号离线";position:absolute;inset:0;top:32px;display:flex;align-items:center;justify-content:center;color:#e74c3c;font-size:16px;background:rgba(0,0,0,.55);z-index:1}
  51. .video-cell .empty{position:absolute;top:32px;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;color:#666;font-size:14px;z-index:1}
  52. /* Loading Overlay */
  53. .loading-overlay{position:absolute;top:32px;left:0;right:0;bottom:0;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.7);z-index:10;flex-direction:column;gap:12px}
  54. .loading-overlay.show{display:flex}
  55. .loading-bar-wrap{width:200px;height:4px;background:#0f3460;border-radius:2px;overflow:hidden}
  56. .loading-bar{height:100%;width:30%;background:linear-gradient(90deg,#3498db,#e94560);border-radius:2px;animation:loadSlide 1.2s ease-in-out infinite}
  57. @keyframes loadSlide{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}
  58. .loading-text{color:#aaa;font-size:12px}
  59. body.rail .sidebar{width:60px}
  60. body.rail .logo .t,body.rail .nav-label,body.rail .side-foot .nav-label{display:none}
  61. </style>
  62. </head>
  63. <body>
  64. <div class="app">
  65. <aside class="sidebar" id="sidebar">
  66. <div class="logo"><img src="/logo.png" alt=""><span class="t">恒达视觉监控平台</span></div>
  67. <nav class="nav">
  68. <a class="nav-item" href="/" style="text-decoration:none"><span class="ico">监</span><span class="nav-label">实时监控</span></a>
  69. <a class="nav-item" href="/#fence" style="text-decoration:none"><span class="ico">围</span><span class="nav-label">电子围栏</span></a>
  70. <a class="nav-item" href="/#alarm" style="text-decoration:none"><span class="ico">警</span><span class="nav-label">报警历史</span></a>
  71. <a class="nav-item" href="/#action" style="text-decoration:none"><span class="ico">动</span><span class="nav-label">动作记录</span></a>
  72. <a class="nav-item" href="/#relay" style="text-decoration:none"><span class="ico">继</span><span class="nav-label">继电器控制</span></a>
  73. <a class="nav-item" href="/#channel" style="text-decoration:none"><span class="ico">通</span><span class="nav-label">通道管理</span></a>
  74. <a class="nav-item" href="/#system" style="text-decoration:none"><span class="ico">系</span><span class="nav-label">系统设置</span></a>
  75. <div style="border-top:1px solid #0f3460;margin:8px 12px"></div>
  76. <a class="nav-item active" href="/preview.html" style="text-decoration:none"><span class="ico">工</span><span class="nav-label">工位预览</span></a>
  77. <a class="nav-item" href="/config.html" style="text-decoration:none"><span class="ico">设</span><span class="nav-label">工位配置</span></a>
  78. </nav>
  79. <div class="side-foot">
  80. <button class="mode-btn auto" id="modeBtn" onclick="toggleMode()">自动模式</button>
  81. <button class="collapse-btn" onclick="document.body.classList.toggle('rail')">« 收起菜单</button>
  82. </div>
  83. </aside>
  84. <main class="content">
  85. <div class="status-bar">
  86. <div class="status-left">
  87. <div class="status-item" id="moduleStatus">通讯状态: <span class="val">初始化</span></div>
  88. <div class="status-item" id="tagStatus">当前卡号: <span class="val">无</span></div>
  89. <div class="status-item" id="hookState">挂钩状态: <span class="val">空闲</span></div>
  90. <div class="status-item" id="bucketStatus">当前工位: <span class="val">无</span></div>
  91. </div>
  92. </div>
  93. <div class="main-body">
  94. <div class="bucket-panel">
  95. <h4>工位列表</h4>
  96. <div id="bucketList"></div>
  97. </div>
  98. <div class="video-area">
  99. <div class="video-cell" id="leftVideo">
  100. <div class="cam-label" id="leftCamLabel">左侧画面</div>
  101. <img id="leftStream" alt="">
  102. <div class="empty" id="leftEmpty">请选择工位</div>
  103. <div class="loading-overlay" id="leftLoading">
  104. <div class="loading-bar-wrap"><div class="loading-bar"></div></div>
  105. <div class="loading-text">正在连接摄像头...</div>
  106. </div>
  107. </div>
  108. <div class="video-cell" id="rightVideo">
  109. <div class="cam-label" id="rightCamLabel">右侧画面</div>
  110. <img id="rightStream" alt="">
  111. <div class="empty" id="rightEmpty">请选择工位</div>
  112. <div class="loading-overlay" id="rightLoading">
  113. <div class="loading-bar-wrap"><div class="loading-bar"></div></div>
  114. <div class="loading-text">正在连接摄像头...</div>
  115. </div>
  116. </div>
  117. </div>
  118. </div>
  119. </main>
  120. </div>
  121. <script>
  122. let buckets = [];
  123. let activeId = -1;
  124. let mode = 'auto';
  125. let moduleType = 'RFID';
  126. let lastTag = '';
  127. let refreshTimer;
  128. let switching = false;
  129. async function loadBuckets() {
  130. try {
  131. const res = await fetch('/api/buckets');
  132. const data = await res.json();
  133. buckets = data.buckets || [];
  134. activeId = data.active_id;
  135. mode = data.mode;
  136. renderBuckets();
  137. updateModeBtn();
  138. } catch (e) { console.error('加载工位失败:', e); }
  139. }
  140. async function loadStatus() {
  141. try {
  142. const res = await fetch('/api/buckets/status');
  143. const data = await res.json();
  144. moduleType = data.module_type || 'RFID';
  145. activeId = data.active_id;
  146. mode = data.mode;
  147. const moduleEl = document.getElementById('moduleStatus');
  148. if (data.rfid) {
  149. const online = data.rfid.online;
  150. moduleEl.innerHTML = `通讯状态: <span class="val ${online?'online':'offline'}">${online?'RFID在线':'RFID离线'}</span>`;
  151. if (data.rfid.last_tag) {
  152. lastTag = data.rfid.last_tag;
  153. document.getElementById('tagStatus').innerHTML = `当前卡号: <span class="val">${lastTag}</span>`;
  154. }
  155. }
  156. if (data.modbus) {
  157. const online = data.modbus.online;
  158. moduleEl.innerHTML = `通讯状态: <span class="val ${online?'online':'offline'}">${online?'Modbus在线':'Modbus离线'}</span>`;
  159. }
  160. if (activeId >= 0) {
  161. const b = buckets.find(x => x.id === activeId);
  162. document.getElementById('bucketStatus').innerHTML = `当前工位: <span class="val">${b ? b.description : activeId}</span>`;
  163. } else {
  164. document.getElementById('bucketStatus').innerHTML = `当前工位: <span class="val">无</span>`;
  165. }
  166. updateModeBtn();
  167. renderBuckets();
  168. } catch (e) { console.error('加载状态失败:', e); }
  169. }
  170. function renderBuckets() {
  171. const el = document.getElementById('bucketList');
  172. el.innerHTML = buckets.map(b => `
  173. <div class="bucket-btn ${b.id === activeId ? 'active' : ''}"
  174. onclick="activateBucket(${b.id})">
  175. <div class="name">${b.description}</div>
  176. <div class="tag">${b.code_x}</div>
  177. </div>
  178. `).join('');
  179. }
  180. function showLoading(left, right) {
  181. document.getElementById('leftLoading').classList.toggle('show', left);
  182. document.getElementById('rightLoading').classList.toggle('show', right);
  183. }
  184. function hideLeftLoading() { document.getElementById('leftLoading').classList.remove('show'); }
  185. function hideRightLoading() { document.getElementById('rightLoading').classList.remove('show'); }
  186. async function activateBucket(id) {
  187. if (switching) return;
  188. switching = true;
  189. activeId = id;
  190. renderBuckets();
  191. const b = buckets.find(x => x.id === id);
  192. const bName = b ? b.description : '';
  193. const leftImg = document.getElementById('leftStream');
  194. const rightImg = document.getElementById('rightStream');
  195. const leftLabel = document.getElementById('leftCamLabel');
  196. const rightLabel = document.getElementById('rightCamLabel');
  197. const leftCell = document.getElementById('leftVideo');
  198. const rightCell = document.getElementById('rightVideo');
  199. const leftEmpty = document.getElementById('leftEmpty');
  200. const rightEmpty = document.getElementById('rightEmpty');
  201. // 断开旧流
  202. leftImg.removeAttribute('src');
  203. rightImg.removeAttribute('src');
  204. leftCell.classList.remove('offline');
  205. rightCell.classList.remove('offline');
  206. const hasLeft = b && b.left_camera && b.left_camera.url;
  207. const hasRight = b && b.right_camera && b.right_camera.url;
  208. // 显示loading
  209. showLoading(hasLeft, hasRight);
  210. if (hasLeft) { leftEmpty.style.display = 'none'; leftLabel.textContent = bName + ' - 左'; }
  211. if (hasRight) { rightEmpty.style.display = 'none'; rightLabel.textContent = bName + ' - 右'; }
  212. // 通知后端切换
  213. try {
  214. await fetch('/api/buckets/activate', {
  215. method: 'POST',
  216. headers: {'Content-Type': 'application/json'},
  217. body: JSON.stringify({id})
  218. });
  219. } catch (e) { console.error('激活失败:', e); }
  220. // 等待后端RTSP重连,再请求MJPEG
  221. await new Promise(r => setTimeout(r, 1500));
  222. let leftReady = false, rightReady = false;
  223. if (hasLeft) {
  224. leftImg.onload = function() {
  225. if (!leftReady) { leftReady = true; hideLeftLoading(); }
  226. };
  227. leftImg.onerror = function() {
  228. leftCell.classList.add('offline');
  229. leftLabel.textContent = bName + ' - 左 (离线)';
  230. hideLeftLoading();
  231. };
  232. leftImg.src = '/mjpeg/0?t=' + Date.now();
  233. } else {
  234. hideLeftLoading();
  235. if (b) { leftLabel.textContent = '左侧画面'; leftEmpty.textContent = '未配置左摄像头'; leftEmpty.style.display = 'flex'; }
  236. }
  237. if (hasRight) {
  238. rightImg.onload = function() {
  239. if (!rightReady) { rightReady = true; hideRightLoading(); }
  240. };
  241. rightImg.onerror = function() {
  242. rightCell.classList.add('offline');
  243. rightLabel.textContent = bName + ' - 右 (离线)';
  244. hideRightLoading();
  245. };
  246. rightImg.src = '/mjpeg/1?t=' + Date.now();
  247. } else {
  248. hideRightLoading();
  249. if (b) { rightLabel.textContent = '右侧画面'; rightEmpty.textContent = '未配置右摄像头'; rightEmpty.style.display = 'flex'; }
  250. }
  251. switching = false;
  252. }
  253. async function toggleMode() {
  254. const newMode = mode === 'auto' ? 'hand' : 'auto';
  255. try {
  256. await fetch('/api/buckets/mode', {
  257. method: 'POST',
  258. headers: {'Content-Type': 'application/json'},
  259. body: JSON.stringify({mode: newMode})
  260. });
  261. mode = newMode;
  262. updateModeBtn();
  263. } catch (e) { console.error('切换模式失败:', e); }
  264. }
  265. function updateModeBtn() {
  266. const btn = document.getElementById('modeBtn');
  267. btn.className = 'mode-btn ' + mode;
  268. btn.textContent = mode === 'auto' ? '自动模式' : '手动模式';
  269. }
  270. async function init() {
  271. await loadBuckets();
  272. await loadStatus();
  273. if (activeId >= 0) {
  274. activateBucket(activeId);
  275. }
  276. refreshTimer = setInterval(loadStatus, 3000);
  277. }
  278. init();
  279. </script>
  280. </body>
  281. </html>