admin-9grid-config.html 60 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400
  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>固定9宫格摄像头配置 · 管理后台</title>
  7. <style>
  8. /* ===== THEME ===== */
  9. :root {
  10. --primary: #1890ff;
  11. --primary-hover: #40a9ff;
  12. --primary-light: #e6f7ff;
  13. --primary-border: #91d5ff;
  14. --success: #52c41a;
  15. --warning: #faad14;
  16. --error: #ff4d4f;
  17. --text-1: #262626;
  18. --text-2: #595959;
  19. --text-3: #8c8c8c;
  20. --text-4: #bfbfbf;
  21. --bg-page: #f5f7fa;
  22. --bg-white: #ffffff;
  23. --bg-gray: #f8f9fa;
  24. --bg-hover: #f0f5ff;
  25. --border: #e8e8e8;
  26. --border-dark: #d9d9d9;
  27. --shadow-sm: 0 1px 4px rgba(0,0,0,0.06);
  28. --shadow: 0 2px 8px rgba(0,0,0,0.09);
  29. --shadow-md: 0 4px 16px rgba(0,0,0,0.1);
  30. --font: -apple-system,'PingFang SC','Microsoft YaHei','Segoe UI',sans-serif;
  31. --radius-sm: 4px;
  32. --radius: 8px;
  33. --radius-lg: 12px;
  34. --cam-bg: #040d1e;
  35. }
  36. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  37. html, body {
  38. width: 100%; height: 100%; overflow: hidden;
  39. background: #e5e9f0; /* 缩放留白区背景 */
  40. font-family: var(--font); color: var(--text-1); font-size: 14px;
  41. -webkit-font-smoothing: antialiased;
  42. }
  43. /* ===== SCALE WRAPPER ===== */
  44. #scale-root {
  45. width: 1920px; height: 1080px;
  46. position: absolute; top: 0; left: 0;
  47. transform-origin: top left;
  48. background: var(--bg-page);
  49. overflow: hidden;
  50. }
  51. ::-webkit-scrollbar { width: 6px; height: 6px; }
  52. ::-webkit-scrollbar-track { background: var(--bg-gray); }
  53. ::-webkit-scrollbar-thumb { background: var(--border-dark); border-radius: 3px; }
  54. ::-webkit-scrollbar-thumb:hover { background: #aaa; }
  55. /* ===== APP SHELL ===== */
  56. .app { display: flex; flex-direction: column; width: 1920px; height: 1080px; }
  57. /* ===== TOP BAR ===== */
  58. .topbar {
  59. flex-shrink: 0; height: 56px;
  60. display: flex; align-items: center; gap: 0; padding: 0 20px;
  61. background: var(--bg-white);
  62. border-bottom: 1px solid var(--border);
  63. box-shadow: var(--shadow-sm); z-index: 100;
  64. }
  65. .topbar-logo {
  66. display: flex; align-items: center; gap: 10px;
  67. padding-right: 20px; border-right: 1px solid var(--border);
  68. flex-shrink: 0; text-decoration: none;
  69. }
  70. .logo-icon {
  71. width: 32px; height: 32px; border-radius: 50%;
  72. background: linear-gradient(135deg, #096dd9, #1890ff);
  73. display: flex; align-items: center; justify-content: center;
  74. font-size: 14px; font-weight: 700; color: #fff; flex-shrink: 0;
  75. }
  76. .logo-text { font-size: 15px; font-weight: 600; color: var(--text-1); letter-spacing: 0.3px; }
  77. .logo-sub { font-size: 11px; color: var(--text-3); }
  78. .topbar-nav {
  79. display: flex; align-items: stretch; height: 100%;
  80. padding-left: 8px; flex: 1; gap: 0;
  81. }
  82. .tn-item {
  83. display: flex; align-items: center; gap: 6px; padding: 0 16px;
  84. font-size: 13px; color: var(--text-2); cursor: pointer;
  85. border-bottom: 2px solid transparent; transition: all 0.2s;
  86. text-decoration: none; white-space: nowrap;
  87. }
  88. .tn-item:hover { color: var(--primary); }
  89. .tn-item.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 500; }
  90. .topbar-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
  91. .tb-icon-btn {
  92. width: 36px; height: 36px;
  93. display: flex; align-items: center; justify-content: center;
  94. border-radius: var(--radius-sm); cursor: pointer; color: var(--text-3);
  95. font-size: 16px; transition: background 0.15s, color 0.15s;
  96. border: none; background: none;
  97. }
  98. .tb-icon-btn:hover { background: var(--bg-hover); color: var(--primary); }
  99. .tb-divider { width: 1px; height: 22px; background: var(--border); margin: 0 8px; }
  100. .tb-back-btn {
  101. display: flex; align-items: center; gap: 5px;
  102. padding: 5px 12px; margin-left: 12px;
  103. border-radius: var(--radius-sm); border: 1px solid var(--border-dark);
  104. background: var(--bg-white); color: var(--text-2);
  105. font-size: 13px; font-family: var(--font); cursor: pointer;
  106. transition: all 0.15s; flex-shrink: 0;
  107. }
  108. .tb-back-btn:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-light); }
  109. .topbar-user {
  110. display: flex; align-items: center; gap: 8px; padding: 5px 10px;
  111. border-radius: var(--radius-sm); cursor: pointer; transition: background 0.15s;
  112. }
  113. .topbar-user:hover { background: var(--bg-hover); }
  114. .user-avatar {
  115. width: 28px; height: 28px; border-radius: 50%;
  116. background: linear-gradient(135deg, #1890ff, #096dd9);
  117. display: flex; align-items: center; justify-content: center;
  118. font-size: 12px; color: #fff; font-weight: 600; flex-shrink: 0;
  119. }
  120. .user-name { font-size: 13px; color: var(--text-2); font-weight: 500; }
  121. .user-role { font-size: 11px; color: var(--text-4); }
  122. /* ===== MAIN AREA ===== */
  123. .main { display: flex; flex: 1; min-height: 0; }
  124. /* ===== LEFT NAV MENU ===== */
  125. .left-nav {
  126. width: 282px; flex-shrink: 0;
  127. display: flex; flex-direction: column;
  128. background: var(--bg-white);
  129. border-right: 1px solid var(--border);
  130. overflow-y: auto; overflow-x: hidden;
  131. user-select: none;
  132. }
  133. .ln-section {
  134. padding: 18px 0 4px;
  135. }
  136. .ln-section + .ln-section { border-top: 1px solid var(--border); }
  137. .ln-section-title {
  138. padding: 0 16px 6px;
  139. font-size: 11px; font-weight: 500; color: var(--text-4);
  140. letter-spacing: 0.5px; text-transform: uppercase;
  141. }
  142. .ln-item {
  143. display: flex; align-items: center; gap: 9px;
  144. padding: 8px 16px; font-size: 13px; color: var(--text-2);
  145. cursor: pointer; transition: all 0.15s;
  146. border-left: 3px solid transparent;
  147. position: relative;
  148. }
  149. .ln-item:hover { background: var(--bg-hover); color: var(--text-1); }
  150. .ln-item.active {
  151. background: var(--primary-light);
  152. color: var(--primary);
  153. border-left-color: var(--primary);
  154. font-weight: 500;
  155. }
  156. .ln-item-icon { font-size: 14px; flex-shrink: 0; }
  157. .ln-item-badge {
  158. margin-left: auto; font-size: 10px; padding: 1px 6px;
  159. border-radius: 10px; background: var(--bg-gray);
  160. color: var(--text-4); border: 1px solid var(--border);
  161. flex-shrink: 0;
  162. }
  163. .ln-item.active .ln-item-badge {
  164. background: rgba(24,144,255,0.12); color: var(--primary);
  165. border-color: var(--primary-border);
  166. }
  167. /* ===== CAMERA TREE SIDEBAR ===== */
  168. .sidebar {
  169. width: 322px; flex-shrink: 0;
  170. display: flex; flex-direction: column;
  171. background: var(--bg-white);
  172. border-right: 1px solid var(--border);
  173. overflow: hidden;
  174. }
  175. .sb-hdr { padding: 14px 16px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
  176. .sb-title {
  177. font-size: 13px; font-weight: 600; color: var(--text-1);
  178. display: flex; align-items: center; justify-content: space-between;
  179. margin-bottom: 10px;
  180. }
  181. .sb-title-badge {
  182. font-size: 11px; color: var(--text-3); background: var(--bg-gray);
  183. padding: 1px 7px; border-radius: 10px; border: 1px solid var(--border); font-weight: 400;
  184. }
  185. .sb-stats { display: flex; gap: 6px; }
  186. .ss-chip {
  187. flex: 1; padding: 6px 8px; border-radius: var(--radius-sm);
  188. font-size: 10px; text-align: center;
  189. background: var(--bg-gray); border: 1px solid var(--border);
  190. }
  191. .ss-chip .sv { font-size: 17px; font-weight: 700; line-height: 1.2; }
  192. .ss-chip.online .sv { color: var(--success); }
  193. .ss-chip.offline .sv { color: var(--error); }
  194. .ss-chip.total .sv { color: var(--primary); }
  195. .ss-chip .sl { color: var(--text-4); font-size: 10px; margin-top: 1px; }
  196. .sb-search { padding: 10px 14px 8px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
  197. .search-inp {
  198. width: 100%; padding: 6px 10px 6px 32px;
  199. border-radius: var(--radius-sm); background: var(--bg-gray);
  200. border: 1px solid var(--border); color: var(--text-1);
  201. font-size: 12px; font-family: var(--font); outline: none; transition: all 0.2s;
  202. background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23bfbfbf' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='M21 21l-4.35-4.35'/%3E%3C/svg%3E");
  203. background-repeat: no-repeat; background-position: 9px center;
  204. }
  205. .search-inp:focus { border-color: var(--primary); background-color: #fff; box-shadow: 0 0 0 2px var(--primary-light); }
  206. .search-inp::placeholder { color: var(--text-4); }
  207. .sb-filter { padding: 6px 14px 8px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
  208. .filter-sel {
  209. width: 100%; padding: 5px 28px 5px 10px;
  210. border-radius: var(--radius-sm); background: var(--bg-white);
  211. border: 1px solid var(--border-dark); color: var(--text-2); font-size: 12px;
  212. font-family: var(--font); outline: none; cursor: pointer; appearance: none;
  213. background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23bfbfbf'/%3E%3C/svg%3E");
  214. background-repeat: no-repeat; background-position: right 10px center; transition: border-color 0.2s;
  215. }
  216. .filter-sel:focus { border-color: var(--primary); box-shadow: 0 0 0 2px var(--primary-light); }
  217. .sb-tree { flex: 1; overflow-y: auto; padding: 6px 10px; }
  218. /* Tree */
  219. .tr-bld { margin-bottom: 2px; }
  220. .tr-bld-lbl {
  221. display: flex; align-items: center; gap: 6px; padding: 6px 8px;
  222. border-radius: var(--radius-sm); font-size: 12px; font-weight: 600; color: var(--text-1);
  223. cursor: pointer; transition: background 0.15s; user-select: none;
  224. }
  225. .tr-bld-lbl:hover { background: var(--bg-hover); }
  226. .tr-arrow { font-size: 9px; color: var(--text-4); transition: transform 0.2s; flex-shrink: 0; }
  227. .tr-arrow.open { transform: rotate(90deg); }
  228. .tr-floors { padding-left: 14px; display: none; }
  229. .tr-floors.open { display: block; }
  230. .tr-floor-lbl {
  231. display: flex; align-items: center; gap: 6px; padding: 4px 8px;
  232. border-radius: var(--radius-sm); font-size: 11px; color: var(--text-3);
  233. cursor: pointer; user-select: none; transition: background 0.15s;
  234. }
  235. .tr-floor-lbl:hover { background: var(--bg-hover); color: var(--text-2); }
  236. .tr-cams { padding-left: 14px; display: none; }
  237. .tr-cams.open { display: block; }
  238. /* Camera item */
  239. .cam-item {
  240. display: flex; align-items: center; gap: 6px; padding: 5px 8px;
  241. border-radius: var(--radius-sm); font-size: 12px; color: var(--text-2);
  242. cursor: pointer; margin-bottom: 1px; border: 1px solid transparent;
  243. transition: all 0.15s; user-select: none;
  244. }
  245. .cam-item:hover { background: var(--bg-hover); border-color: var(--primary-border); }
  246. .cam-item.selected-cam { background: var(--primary-light); border-color: var(--primary-border); color: var(--primary); }
  247. .cam-item.dragging { opacity: 0.4; }
  248. .cam-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
  249. .cam-dot.online { background: var(--success); }
  250. .cam-dot.offline { background: var(--error); }
  251. .cam-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
  252. .cam-badge { font-size: 10px; padding: 1px 0; border-radius: 3px; flex-shrink: 0; white-space: nowrap; min-width: 38px; text-align: center; }
  253. .cam-badge.ai { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
  254. .cam-badge.normal { background: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; }
  255. .cam-badge.used { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
  256. .cam-drag { color: var(--text-4); font-size: 12px; cursor: grab; flex-shrink: 0; }
  257. .cam-drag:active { cursor: grabbing; }
  258. /* ===== CENTER CONTENT ===== */
  259. .content {
  260. flex: 1; min-width: 0; display: flex; flex-direction: column;
  261. padding: 0; overflow: hidden; background: var(--bg-page);
  262. }
  263. .content-topbar {
  264. display: flex; align-items: center; padding: 10px 12px 0 8px;
  265. flex-shrink: 0; gap: 12px;
  266. }
  267. .breadcrumb { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-3); flex: 1; }
  268. .breadcrumb a { color: var(--text-3); text-decoration: none; cursor: pointer; transition: color 0.15s; }
  269. .breadcrumb a:hover { color: var(--primary); }
  270. .breadcrumb .sep { color: var(--text-4); font-size: 11px; }
  271. .breadcrumb .current { color: var(--text-1); font-weight: 500; }
  272. /* View Tabs */
  273. .view-tabs {
  274. display: flex; align-items: center; gap: 0;
  275. border: 1px solid var(--border-dark); border-radius: var(--radius-sm); overflow: hidden;
  276. flex-shrink: 0;
  277. }
  278. .view-tab {
  279. padding: 4px 16px; font-size: 13px; font-family: var(--font);
  280. background: var(--bg-white); color: var(--text-2);
  281. border: none; cursor: pointer; transition: background 0.15s, color 0.15s;
  282. border-right: 1px solid var(--border-dark); white-space: nowrap;
  283. }
  284. .view-tab:last-child { border-right: none; }
  285. .view-tab:hover { background: var(--bg-hover); color: var(--primary); }
  286. .view-tab.active { background: var(--primary); color: #fff; font-weight: 500; }
  287. .content-body {
  288. padding: 8px 12px 12px 8px; display: flex; flex-direction: column;
  289. flex: 1; min-height: 0; gap: 0; overflow: hidden;
  290. }
  291. /* Config card */
  292. .config-card {
  293. background: var(--bg-white); border-radius: var(--radius);
  294. border: 1px solid var(--border); box-shadow: var(--shadow-sm);
  295. display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden;
  296. }
  297. .card-header {
  298. display: flex; align-items: center; justify-content: space-between;
  299. padding: 11px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0;
  300. gap: 12px;
  301. }
  302. .card-title-wrap { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
  303. .card-title-icon { font-size: 17px; }
  304. .card-title-text { font-size: 14px; font-weight: 600; color: var(--text-1); white-space: nowrap; }
  305. .card-title-sub { font-size: 12px; color: var(--text-3); white-space: nowrap; }
  306. .card-title-divider { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }
  307. /* Quick ops in header */
  308. .header-qops { display: flex; align-items: center; gap: 6px; }
  309. .qop-btn-sm {
  310. display: inline-flex; align-items: center; gap: 5px;
  311. padding: 5px 12px; border-radius: var(--radius-sm);
  312. font-size: 12px; cursor: pointer; font-family: var(--font);
  313. border: 1px solid var(--border-dark); background: var(--bg-white);
  314. color: var(--text-2); transition: all 0.15s; white-space: nowrap;
  315. }
  316. .qop-btn-sm:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-light); }
  317. .qop-btn-sm.danger:hover { border-color: var(--error); color: var(--error); background: #fff2f0; }
  318. .live-tag { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--success); flex-shrink: 0; }
  319. .live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); flex-shrink: 0; }
  320. .hint-tag {
  321. display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px;
  322. border-radius: var(--radius-sm); background: #fffbe6; border: 1px solid #ffe58f;
  323. font-size: 11px; color: #d48806; white-space: nowrap;
  324. }
  325. /* 9-Slot Grid */
  326. .slot-grid {
  327. display: grid;
  328. gap: 10px; flex: 1; min-height: 0; padding: 14px;
  329. }
  330. .slot {
  331. position: relative; border-radius: 6px; overflow: hidden;
  332. background: var(--cam-bg); border: 2px solid #dde3ed; cursor: pointer;
  333. transition: border-color 0.2s, box-shadow 0.2s;
  334. display: flex; align-items: center; justify-content: center; min-height: 0;
  335. }
  336. .slot:hover:not(.selected) { border-color: var(--primary-border); box-shadow: 0 0 0 2px var(--primary-light); }
  337. .slot.selected { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(24,144,255,0.15); }
  338. .slot.slot1-required { border-color: #b7eb8f; }
  339. .slot.slot1-required.selected { border-color: var(--success); box-shadow: 0 0 0 3px rgba(82,196,26,0.12); }
  340. .slot.slot1-warn { border-color: #ffd591; }
  341. .slot.drag-over { border-color: var(--primary); border-style: dashed; background: rgba(24,144,255,0.04); }
  342. .slot-num {
  343. position: absolute; top: 6px; left: 6px; z-index: 5;
  344. min-width: 20px; height: 20px; padding: 0 6px; border-radius: 4px;
  345. background: rgba(0,0,0,0.55);
  346. display: flex; align-items: center; justify-content: center;
  347. font-size: 10px; font-weight: 700; color: rgba(255,255,255,0.9); transition: all 0.2s;
  348. }
  349. .slot.selected .slot-num { background: var(--primary); color: #fff; }
  350. .slot-ai-tag {
  351. position: absolute; top: 6px; right: 6px; z-index: 5;
  352. font-size: 9px; padding: 2px 6px; border-radius: 3px;
  353. background: rgba(0,30,15,0.85); color: #73d13d; border: 1px solid rgba(115,209,61,0.4);
  354. }
  355. .slot-ai-tag.req-warn { background: rgba(255,240,200,0.9); color: #d48806; border-color: #ffd591; }
  356. .slot-empty {
  357. display: flex; flex-direction: column; align-items: center; gap: 6px;
  358. user-select: none; pointer-events: none;
  359. }
  360. .slot-empty-icon { font-size: 22px; opacity: 0.3; }
  361. .slot-empty-text { font-size: 11px; color: rgba(255,255,255,0.22); }
  362. .slot-empty-hint { font-size: 10px; color: rgba(255,255,255,0.13); }
  363. .slot canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
  364. .slot-voverlay { position: absolute; inset: 0; background: linear-gradient(180deg,rgba(0,0,0,0.45) 0%,transparent 28%,transparent 62%,rgba(0,0,0,0.6) 100%); pointer-events: none; z-index: 2; }
  365. .slot-cam-name { position: absolute; bottom: 6px; left: 8px; font-size: 10px; color: rgba(255,255,255,0.92); background: rgba(0,0,0,0.6); padding: 2px 6px; border-radius: 3px; z-index: 5; max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  366. .slot-cam-loc { position: absolute; bottom: 6px; right: 8px; font-size: 9px; color: rgba(255,255,255,0.5); background: rgba(0,0,0,0.5); padding: 2px 6px; border-radius: 3px; z-index: 5; }
  367. .slot-rec { position: absolute; top: 6px; right: 6px; display: flex; align-items: center; gap: 4px; font-size: 9px; color: #ff4d4f; background: rgba(0,0,0,0.6); padding: 2px 6px; border-radius: 3px; z-index: 5; }
  368. .slot-rec::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: #ff4d4f; animation: blinkRed 1s ease-in-out infinite; }
  369. .slot-offline-tag { position: absolute; top: 6px; right: 6px; font-size: 9px; color: rgba(255,255,255,0.35); background: rgba(0,0,0,0.6); padding: 2px 6px; border-radius: 3px; z-index: 5; }
  370. @keyframes blinkRed { 0%,100%{opacity:1}50%{opacity:0.2} }
  371. .slot-hover-actions {
  372. position: absolute; inset: 0; z-index: 10;
  373. display: flex; align-items: center; justify-content: center; gap: 8px;
  374. background: rgba(0,0,0,0.55); opacity: 0; transition: opacity 0.15s;
  375. }
  376. .slot:hover:not(.empty) .slot-hover-actions { opacity: 1; }
  377. .slot-act-btn {
  378. padding: 4px 12px; border-radius: 4px; font-size: 11px; cursor: pointer;
  379. border: 1px solid rgba(255,255,255,0.35); background: rgba(255,255,255,0.12);
  380. color: rgba(255,255,255,0.9); transition: all 0.15s; font-family: var(--font);
  381. backdrop-filter: blur(4px);
  382. }
  383. .slot-act-btn:hover { background: rgba(24,144,255,0.5); border-color: #1890ff; }
  384. .slot-act-btn.del { border-color: rgba(255,77,79,0.5); color: #ff7875; }
  385. .slot-act-btn.del:hover { background: rgba(255,77,79,0.35); border-color: #ff4d4f; }
  386. .ai-det-box {
  387. position: absolute; border: 1.5px solid #ff4d4f; background: rgba(255,77,79,0.07);
  388. border-radius: 3px; z-index: 6; animation: detBox 1.5s ease-in-out infinite;
  389. }
  390. @keyframes detBox { 0%,100%{border-color:#ff4d4f}50%{border-color:#ffa39e} }
  391. .ai-det-lbl {
  392. position: absolute; bottom: 100%; left: 0; white-space: nowrap;
  393. font-size: 8px; background: #ff4d4f; color: #fff; padding: 1px 5px;
  394. border-radius: 2px; margin-bottom: 2px;
  395. }
  396. /* Stats bar */
  397. .stats-bar {
  398. display: flex; align-items: center; gap: 14px; flex-shrink: 0;
  399. padding: 8px 16px; border-top: 1px solid var(--border); background: var(--bg-gray);
  400. }
  401. .sb-item { display: flex; align-items: center; gap: 6px; font-size: 12px; white-space: nowrap; }
  402. .sb-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
  403. .sb-lbl { color: var(--text-3); }
  404. .sb-val { font-weight: 600; color: var(--text-1); }
  405. .sb-val.ok { color: var(--success); }
  406. .sb-val.warn { color: var(--warning); }
  407. .sb-val.err { color: var(--error); }
  408. .sb-sep { width: 1px; height: 16px; background: var(--border); flex-shrink: 0; }
  409. .sb-constraint {
  410. margin-left: auto; display: flex; align-items: center; gap: 5px;
  411. font-size: 11px; padding: 3px 10px; border-radius: 4px; flex-shrink: 0;
  412. }
  413. .sb-constraint.ok { background: #f6ffed; border: 1px solid #b7eb8f; color: var(--success); }
  414. .sb-constraint.err { background: #fff2f0; border: 1px solid #ffa39e; color: var(--error); animation: blinkRed 1.8s ease-in-out infinite; }
  415. /* ===== BOTTOM ACTION BAR ===== */
  416. .bottombar {
  417. flex-shrink: 0; height: 52px;
  418. display: flex; align-items: center; justify-content: space-between;
  419. padding: 0 20px; background: var(--bg-white);
  420. border-top: 1px solid var(--border);
  421. box-shadow: 0 -2px 8px rgba(0,0,0,0.05); z-index: 100;
  422. }
  423. .bb-info { display: flex; align-items: center; gap: 12px; font-size: 12px; color: var(--text-3); }
  424. .bb-live { display: flex; align-items: center; gap: 5px; }
  425. .bb-live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); animation: pulse 2s ease-in-out infinite; }
  426. @keyframes pulse { 0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.3);opacity:0.7} }
  427. .bb-sep { width: 1px; height: 14px; background: var(--border); }
  428. .bb-btns { display: flex; align-items: center; gap: 8px; }
  429. /* Apply toggle switch */
  430. .apply-toggle-wrap {
  431. display: flex; align-items: center; gap: 7px;
  432. padding: 4px 12px 4px 10px; margin-right: 4px;
  433. border-radius: var(--radius-sm); background: var(--bg-gray);
  434. border: 1px solid var(--border);
  435. }
  436. .apply-toggle-lbl { font-size: 12px; color: var(--text-2); white-space: nowrap; user-select: none; cursor: pointer; }
  437. .toggle-switch {
  438. position: relative; width: 32px; height: 18px; flex-shrink: 0; cursor: pointer;
  439. }
  440. .toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
  441. .toggle-track {
  442. position: absolute; inset: 0; border-radius: 9px;
  443. background: var(--border-dark); transition: background 0.2s;
  444. }
  445. .toggle-thumb {
  446. position: absolute; top: 2px; left: 2px;
  447. width: 14px; height: 14px; border-radius: 50%;
  448. background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
  449. transition: transform 0.2s;
  450. }
  451. .toggle-switch input:checked ~ .toggle-track { background: var(--primary); }
  452. .toggle-switch input:checked ~ .toggle-thumb { transform: translateX(14px); }
  453. .btn {
  454. padding: 6px 18px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 500;
  455. cursor: pointer; border: 1px solid var(--border-dark); font-family: var(--font);
  456. transition: all 0.2s; display: inline-flex; align-items: center; gap: 5px;
  457. }
  458. .btn-default { background: var(--bg-white); color: var(--text-2); }
  459. .btn-default:hover { border-color: var(--primary); color: var(--primary); }
  460. .btn-outline { background: var(--bg-white); border-color: var(--primary); color: var(--primary); }
  461. .btn-outline:hover { background: var(--primary-light); }
  462. .btn-sec { background: var(--bg-white); border-color: var(--primary); color: var(--primary); }
  463. .btn-sec:hover { background: var(--primary-light); }
  464. .btn-pri { background: var(--primary); border-color: var(--primary); color: #fff; }
  465. .btn-pri:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
  466. .btn-pri:active { transform: scale(0.98); }
  467. /* ===== TOAST ===== */
  468. .toast {
  469. position: fixed; bottom: 64px; left: 50%;
  470. transform: translateX(-50%) translateY(12px);
  471. padding: 8px 20px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 500;
  472. background: rgba(0,0,0,0.75); color: #fff; box-shadow: var(--shadow-md);
  473. z-index: 9999; opacity: 0; transition: all 0.25s; pointer-events: none;
  474. }
  475. .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
  476. .toast.success { background: #52c41a; }
  477. .toast.warn { background: #faad14; }
  478. .toast.err { background: #ff4d4f; }
  479. /* ===== PREVIEW MODAL ===== */
  480. #preview-modal {
  481. position: fixed; inset: 0; z-index: 9998;
  482. display: none; align-items: center; justify-content: center;
  483. background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
  484. }
  485. #preview-modal.show { display: flex; animation: fadeIn 0.22s ease; }
  486. @keyframes fadeIn { from{opacity:0;transform:scale(0.97)}to{opacity:1;transform:scale(1)} }
  487. .prev-inner {
  488. width: 78vw; max-width: 1100px; border-radius: var(--radius-lg); overflow: hidden;
  489. background: var(--bg-white); border: 1px solid var(--border);
  490. box-shadow: 0 20px 60px rgba(0,0,0,0.2);
  491. }
  492. .prev-hdr {
  493. padding: 14px 20px; display: flex; align-items: center; justify-content: space-between;
  494. border-bottom: 1px solid var(--border); background: var(--bg-gray);
  495. }
  496. .prev-title { font-size: 15px; font-weight: 600; color: var(--text-1); }
  497. .prev-sub { font-size: 12px; color: var(--text-3); margin-top: 2px; }
  498. .prev-close {
  499. width: 28px; height: 28px; border-radius: var(--radius-sm); cursor: pointer;
  500. border: 1px solid var(--border); background: var(--bg-white); color: var(--text-3);
  501. font-size: 13px; display: flex; align-items: center; justify-content: center; transition: all 0.15s;
  502. }
  503. .prev-close:hover { border-color: var(--error); color: var(--error); background: #fff2f0; }
  504. .prev-body { padding: 16px; background: #1a1a2e; }
  505. .prev-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; }
  506. .prev-cell {
  507. aspect-ratio: 16/9; border-radius: 5px; overflow: hidden;
  508. background: var(--cam-bg); border: 1px solid rgba(255,255,255,0.08);
  509. position: relative; display: flex; align-items: center; justify-content: center;
  510. }
  511. .prev-cell canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
  512. .prev-cell-ov { position: absolute; inset: 0; background: linear-gradient(180deg,rgba(0,0,0,0.4) 0%,transparent 25%,transparent 68%,rgba(0,0,0,0.55) 100%); pointer-events: none; z-index: 2; }
  513. .prev-cell-num { position: absolute; top: 5px; left: 6px; font-size: 10px; font-weight: 700; color: rgba(255,255,255,0.9); background: rgba(0,0,0,0.6); padding: 1px 6px; border-radius: 3px; z-index: 5; }
  514. .prev-cell-name{ position: absolute; bottom: 5px; left: 6px; font-size: 10px; color: rgba(255,255,255,0.85); background: rgba(0,0,0,0.6); padding: 1px 6px; border-radius: 3px; z-index: 5; max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  515. .prev-ai-tag { position: absolute; top: 5px; right: 6px; font-size: 8px; background: rgba(0,30,15,0.85); color: #73d13d; border: 1px solid rgba(115,209,61,0.35); padding: 1px 5px; border-radius: 3px; z-index: 5; }
  516. .prev-ftr { padding: 12px 20px; display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--border); }
  517. </style>
  518. </head>
  519. <body>
  520. <div id="scale-root">
  521. <div class="app">
  522. <!-- ===== TOP BAR ===== -->
  523. <div class="topbar">
  524. <div class="topbar-logo">
  525. <div class="logo-icon">安</div>
  526. <div>
  527. <div class="logo-text">安科院管理后台</div>
  528. <div class="logo-sub">实验室安全智能监测与管控中心</div>
  529. </div>
  530. </div>
  531. <button class="tb-back-btn" onclick="history.back()" title="返回上一页">
  532. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
  533. 返回
  534. </button>
  535. <nav class="topbar-nav">
  536. <a class="tn-item" href="#">首页</a>
  537. <a class="tn-item active" href="#">基础结构管理</a>
  538. <a class="tn-item" href="#">实验室管理</a>
  539. <a class="tn-item" href="#">系统配置</a>
  540. <a class="tn-item" href="#">数据报表</a>
  541. </nav>
  542. <div class="topbar-right">
  543. <button class="tb-icon-btn" title="查看大屏" onclick="window.open('index-v2.html','_blank')">⎋</button>
  544. <button class="tb-icon-btn" title="系统通知">🔔</button>
  545. <div class="tb-divider"></div>
  546. <div class="topbar-user">
  547. <div class="user-avatar">管</div>
  548. <div>
  549. <div class="user-name">系统管理员</div>
  550. <div class="user-role">超级管理员</div>
  551. </div>
  552. </div>
  553. </div>
  554. </div>
  555. <!-- ===== MAIN ===== -->
  556. <div class="main">
  557. <!-- ===== LEFT NAV MENU ===== -->
  558. <div class="left-nav">
  559. <div class="ln-section">
  560. <div class="ln-section-title">基础结构管理</div>
  561. <div class="ln-item" onclick="void 0">
  562. <span class="ln-item-icon">🖥️</span>实时监控大屏
  563. </div>
  564. <div class="ln-item active">
  565. <span class="ln-item-icon">📺</span>数据可视化配置
  566. <span class="ln-item-badge">配置</span>
  567. </div>
  568. <div class="ln-item" onclick="void 0">
  569. <span class="ln-item-icon">📷</span>摄像头管理
  570. <span class="ln-item-badge">21</span>
  571. </div>
  572. <div class="ln-item" onclick="void 0">
  573. <span class="ln-item-icon">🗺️</span>点位地图
  574. </div>
  575. </div>
  576. <div class="ln-section">
  577. <div class="ln-section-title">实验室管理</div>
  578. <div class="ln-item" onclick="void 0">
  579. <span class="ln-item-icon">🔬</span>实验室列表
  580. <span class="ln-item-badge">128</span>
  581. </div>
  582. <div class="ln-item" onclick="void 0">
  583. <span class="ln-item-icon">📊</span>安全分级
  584. </div>
  585. <div class="ln-item" onclick="void 0">
  586. <span class="ln-item-icon">👥</span>人员管理
  587. </div>
  588. <div class="ln-item" onclick="void 0">
  589. <span class="ln-item-icon">⚗️</span>化学品管理
  590. </div>
  591. </div>
  592. <div class="ln-section">
  593. <div class="ln-section-title">系统设置</div>
  594. <div class="ln-item" onclick="void 0">
  595. <span class="ln-item-icon">👤</span>用户管理
  596. </div>
  597. <div class="ln-item" onclick="void 0">
  598. <span class="ln-item-icon">🔔</span>预警配置
  599. </div>
  600. <div class="ln-item" onclick="void 0">
  601. <span class="ln-item-icon">📋</span>操作日志
  602. </div>
  603. </div>
  604. </div>
  605. <!-- ===== CAMERA TREE SIDEBAR ===== -->
  606. <div class="sidebar">
  607. <div class="sb-hdr">
  608. <div class="sb-title">
  609. 摄像头库
  610. <span class="sb-title-badge" id="cnt-total-badge">-- 台</span>
  611. </div>
  612. <div class="sb-stats">
  613. <div class="ss-chip online">
  614. <div class="sv" id="cnt-online">--</div>
  615. <div class="sl">在线</div>
  616. </div>
  617. <div class="ss-chip offline">
  618. <div class="sv" id="cnt-offline">--</div>
  619. <div class="sl">离线</div>
  620. </div>
  621. <div class="ss-chip total">
  622. <div class="sv" id="cnt-total">--</div>
  623. <div class="sl">总计</div>
  624. </div>
  625. </div>
  626. </div>
  627. <div class="sb-search">
  628. <input class="search-inp" type="text" placeholder="搜索摄像头 / 房间号…" oninput="filterCams(this.value)">
  629. </div>
  630. <div class="sb-filter">
  631. <select class="filter-sel" onchange="filterUnit(this.value)">
  632. <option value="">全部二级单位</option>
  633. <option>化学研究所</option>
  634. <option>物理研究所</option>
  635. <option>生物研究所</option>
  636. <option>材料研究所</option>
  637. <option>工程研究所</option>
  638. </select>
  639. </div>
  640. <div class="sb-tree" id="cam-tree"></div>
  641. </div>
  642. <!-- ===== CENTER CONTENT ===== -->
  643. <div class="content">
  644. <div class="content-topbar">
  645. <div class="breadcrumb">
  646. <a href="#">首页</a>
  647. <span class="sep">›</span>
  648. <a href="#">基础结构管理</a>
  649. <span class="sep">›</span>
  650. <span class="current">数据可视化配置</span>
  651. </div>
  652. <div class="view-tabs">
  653. <button class="view-tab active" id="tab-btn-9" onclick="switchGridMode(9)">安全大厦</button>
  654. <button class="view-tab" id="tab-btn-4" onclick="switchGridMode(4)">基地</button>
  655. </div>
  656. </div>
  657. <div class="content-body">
  658. <div class="config-card">
  659. <!-- Card Header with quick ops -->
  660. <div class="card-header">
  661. <div class="card-title-wrap">
  662. <span class="card-title-icon">📺</span>
  663. <span class="card-title-text">安全大厦监控配置</span>
  664. <span class="card-title-divider"></span>
  665. <span class="card-title-sub">槽位1须为 AI 摄像头 &nbsp;·&nbsp; 保存后即时生效</span>
  666. <span class="card-title-divider"></span>
  667. <div class="live-tag">
  668. <span class="live-dot"></span>大屏实时同步
  669. </div>
  670. </div>
  671. <div class="header-qops">
  672. <div class="hint-tag">💡 点选摄像头 → 点击槽位,或直接拖拽</div>
  673. <button class="qop-btn-sm danger" onclick="clearAll()">🗑 清空全部</button>
  674. </div>
  675. </div>
  676. <div class="slot-grid" id="slot-grid"></div>
  677. <div class="stats-bar">
  678. <div class="sb-item">
  679. <div class="sb-dot" style="background:var(--primary)"></div>
  680. <span class="sb-lbl">已配置</span>
  681. <span class="sb-val" id="st-configured">0 / 9</span> </div>
  682. <div class="sb-sep"></div>
  683. <div class="sb-item">
  684. <div class="sb-dot" style="background:var(--success)"></div>
  685. <span class="sb-lbl">AI槽位</span>
  686. <span class="sb-val" id="st-ai">--</span>
  687. </div>
  688. <div class="sb-sep"></div>
  689. <div class="sb-item">
  690. <div class="sb-dot" style="background:var(--warning)"></div>
  691. <span class="sb-lbl">待配置</span>
  692. <span class="sb-val" id="st-empty">9</span>
  693. </div>
  694. <div class="sb-sep"></div>
  695. <div class="sb-item">
  696. <span class="sb-lbl">在线率</span>
  697. <span class="sb-val" id="st-online-rate">-- %</span>
  698. </div>
  699. <div class="sb-constraint" id="ai-badge"></div>
  700. </div>
  701. </div>
  702. </div>
  703. </div>
  704. </div><!-- /main -->
  705. <!-- ===== BOTTOM BAR ===== -->
  706. <div class="bottombar">
  707. <div class="bb-info">
  708. <div class="bb-live">
  709. <div class="bb-live-dot"></div>
  710. <span>系统运行正常</span>
  711. </div>
  712. <div class="bb-sep"></div>
  713. <span id="bb-save-text">尚未保存</span>
  714. <div class="bb-sep"></div>
  715. <span id="bb-validity"></span>
  716. </div>
  717. <div class="bb-btns">
  718. <label class="apply-toggle-wrap">
  719. <span class="apply-toggle-lbl">立即生效</span>
  720. <label class="toggle-switch">
  721. <input type="checkbox" id="apply-toggle" checked>
  722. <div class="toggle-track"></div>
  723. <div class="toggle-thumb"></div>
  724. </label>
  725. </label>
  726. <button class="btn btn-default" onclick="resetConfig()">重置</button>
  727. <button class="btn btn-outline" onclick="openPreview()">预览效果</button>
  728. <button class="btn btn-pri" onclick="doSave()">💾 保存</button>
  729. </div>
  730. </div>
  731. </div><!-- /app -->
  732. </div><!-- /scale-root -->
  733. <!-- Toast --><div class="toast" id="toast"></div>
  734. <!-- Preview Modal -->
  735. <div id="preview-modal">
  736. <div class="prev-inner">
  737. <div class="prev-hdr">
  738. <div>
  739. <div class="prev-title">📺 大屏「默认」9宫格预览</div>
  740. <div class="prev-sub">以下为当前配置在大屏中的实际展示效果</div>
  741. </div>
  742. <div class="prev-close" onclick="closePreview()">✕</div>
  743. </div>
  744. <div class="prev-body">
  745. <div class="prev-grid" id="prev-grid"></div>
  746. </div>
  747. <div class="prev-ftr">
  748. <button class="btn btn-default" onclick="closePreview()">关闭</button>
  749. <button class="btn btn-pri" onclick="closePreview();document.getElementById('apply-toggle').checked=true;doSave()">确认并保存生效</button>
  750. </div>
  751. </div>
  752. </div>
  753. <script>
  754. /* ===================================================
  755. DATA
  756. =================================================== */
  757. const CAM_DB = [
  758. {id:'A301-1', name:'A301 有机合成室 #1', code:'A301', labName:'有机合成实验室', floor:'3层', building:'综合实验楼A', unit:'化学研究所', type:'ai', status:'online'},
  759. {id:'A301-2', name:'A301 有机合成室 #2', code:'A301', labName:'有机合成实验室', floor:'3层', building:'综合实验楼A', unit:'化学研究所', type:'normal', status:'online'},
  760. {id:'A302-1', name:'A302 核磁共振室 #1', code:'A302', labName:'核磁共振实验室', floor:'3层', building:'综合实验楼A', unit:'化学研究所', type:'ai', status:'online'},
  761. {id:'A302-2', name:'A302 核磁共振室 #2', code:'A302', labName:'核磁共振实验室', floor:'3层', building:'综合实验楼A', unit:'化学研究所', type:'normal', status:'offline'},
  762. {id:'A303-1', name:'A303 质谱分析室 #1', code:'A303', labName:'质谱分析实验室', floor:'3层', building:'综合实验楼A', unit:'化学研究所', type:'normal', status:'online'},
  763. {id:'A303-2', name:'A303 质谱分析室 #2', code:'A303', labName:'质谱分析实验室', floor:'3层', building:'综合实验楼A', unit:'化学研究所', type:'normal', status:'online'},
  764. {id:'A201-1', name:'A201 化学合成室 #1', code:'A201', labName:'化学合成实验室', floor:'2层', building:'综合实验楼A', unit:'化学研究所', type:'ai', status:'online'},
  765. {id:'A201-2', name:'A201 化学合成室 #2', code:'A201', labName:'化学合成实验室', floor:'2层', building:'综合实验楼A', unit:'化学研究所', type:'normal', status:'online'},
  766. {id:'A202-1', name:'A202 样品制备室 #1', code:'A202', labName:'样品制备实验室', floor:'2层', building:'综合实验楼A', unit:'化学研究所', type:'normal', status:'offline'},
  767. {id:'B401-1', name:'B401 激光实验室 #1', code:'B401', labName:'激光物理实验室', floor:'4层', building:'综合实验楼B', unit:'物理研究所', type:'ai', status:'online'},
  768. {id:'B401-2', name:'B401 激光实验室 #2', code:'B401', labName:'激光物理实验室', floor:'4层', building:'综合实验楼B', unit:'物理研究所', type:'normal', status:'online'},
  769. {id:'B402-1', name:'B402 精密光学室 #1', code:'B402', labName:'精密光学实验室', floor:'4层', building:'综合实验楼B', unit:'物理研究所', type:'normal', status:'offline'},
  770. {id:'B301-1', name:'B301 量子物理室 #1', code:'B301', labName:'量子物理实验室', floor:'3层', building:'综合实验楼B', unit:'物理研究所', type:'ai', status:'online'},
  771. {id:'B301-2', name:'B301 量子物理室 #2', code:'B301', labName:'量子物理实验室', floor:'3层', building:'综合实验楼B', unit:'物理研究所', type:'normal', status:'online'},
  772. {id:'B201-1', name:'B201 超导实验室 #1', code:'B201', labName:'超导材料实验室', floor:'2层', building:'综合实验楼B', unit:'物理研究所', type:'normal', status:'online'},
  773. {id:'C101-1', name:'C101 细胞培养室 #1', code:'C101', labName:'细胞培养实验室', floor:'1层', building:'科研中心楼', unit:'生物研究所', type:'ai', status:'online'},
  774. {id:'C101-2', name:'C101 细胞培养室 #2', code:'C101', labName:'细胞培养实验室', floor:'1层', building:'科研中心楼', unit:'生物研究所', type:'normal', status:'online'},
  775. {id:'C201-1', name:'C201 基因检测室 #1', code:'C201', labName:'基因检测实验室', floor:'2层', building:'科研中心楼', unit:'生物研究所', type:'ai', status:'online'},
  776. {id:'C201-2', name:'C201 基因检测室 #2', code:'C201', labName:'基因检测实验室', floor:'2层', building:'科研中心楼', unit:'生物研究所', type:'normal', status:'offline'},
  777. {id:'C301-1', name:'C301 材料测试室 #1', code:'C301', labName:'材料测试实验室', floor:'3层', building:'科研中心楼', unit:'材料研究所', type:'ai', status:'online'},
  778. {id:'C302-1', name:'C302 样品分析室 #1', code:'C302', labName:'样品分析实验室', floor:'3层', building:'科研中心楼', unit:'材料研究所', type:'normal', status:'online'},
  779. ];
  780. let slots9 = Array.from({length:9}, (_,i) => ({ pos: i + 1, camId: null, aiRequired: i === 0 }));
  781. let slots4 = Array.from({length:4}, (_,i) => ({ pos: i + 1, camId: null, aiRequired: i === 0 }));
  782. const DEFAULT_CONFIG = ['A301-1','A302-1','A303-1','B401-1','B301-1','C101-1','A201-1','B201-1','C301-1'];
  783. DEFAULT_CONFIG.forEach((id,i) => slots9[i].camId = id);
  784. let savedSnapshot9 = DEFAULT_CONFIG.slice();
  785. let savedSnapshot4 = [null,null,null,null];
  786. let gridMode = 9;
  787. let slots = slots9;
  788. let savedSnapshot = savedSnapshot9;
  789. let selectedSlotPos = null;
  790. let selectedCamId = null;
  791. /* ===================================================
  792. INIT
  793. =================================================== */
  794. document.addEventListener('DOMContentLoaded', () => {
  795. buildTree(); buildGrid(); updateStats(); updateCamCounts();
  796. fitScreen();
  797. });
  798. /* ===================================================
  799. SCREEN FIT 1920×1080
  800. =================================================== */
  801. function fitScreen() {
  802. const W = 1920, H = 1080;
  803. const scale = Math.min(window.innerWidth / W, window.innerHeight / H);
  804. const el = document.getElementById('scale-root');
  805. el.style.transform = `scale(${scale})`;
  806. el.style.left = Math.round((window.innerWidth - W * scale) / 2) + 'px';
  807. el.style.top = Math.round((window.innerHeight - H * scale) / 2) + 'px';
  808. }
  809. window.addEventListener('resize', fitScreen);
  810. /* ===================================================
  811. CAMERA TREE
  812. =================================================== */
  813. function buildTree() {
  814. const grouped = {};
  815. CAM_DB.forEach(c => {
  816. if (!grouped[c.building]) grouped[c.building] = { unit: c.unit, floors: {} };
  817. if (!grouped[c.building].floors[c.floor]) grouped[c.building].floors[c.floor] = {};
  818. const key = c.code + '|' + c.labName;
  819. if (!grouped[c.building].floors[c.floor][key])
  820. grouped[c.building].floors[c.floor][key] = { labName: c.labName, code: c.code, cams: [] };
  821. grouped[c.building].floors[c.floor][key].cams.push(c);
  822. });
  823. const tree = document.getElementById('cam-tree');
  824. tree.innerHTML = '';
  825. Object.entries(grouped).forEach(([bld, bldData], bi) => {
  826. const bldDiv = document.createElement('div');
  827. bldDiv.className = 'tr-bld';
  828. bldDiv.dataset.unit = bldData.unit;
  829. const bldLbl = document.createElement('div');
  830. bldLbl.className = 'tr-bld-lbl';
  831. const bldFloors = document.createElement('div');
  832. bldFloors.className = 'tr-floors' + (bi < 2 ? ' open' : '');
  833. bldLbl.innerHTML = `
  834. <span class="tr-arrow ${bi < 2 ? 'open' : ''}">▶</span>
  835. <span>🏢</span>
  836. <span style="flex:1">${bld}</span>
  837. <span style="font-size:10px;color:var(--text-4)">${bldData.unit}</span>
  838. `;
  839. bldLbl.addEventListener('click', () => {
  840. const open = bldFloors.classList.toggle('open');
  841. bldLbl.querySelector('.tr-arrow').classList.toggle('open', open);
  842. });
  843. Object.entries(bldData.floors).forEach(([floor, floorData], fi) => {
  844. const floorDiv = document.createElement('div');
  845. const floorLbl = document.createElement('div');
  846. floorLbl.className = 'tr-floor-lbl';
  847. const camList = document.createElement('div');
  848. camList.className = 'tr-cams' + (fi === 0 ? ' open' : '');
  849. floorLbl.innerHTML = `<span class="tr-arrow ${fi===0?'open':''}">▶</span><span>📐</span><span>${floor}</span>`;
  850. floorLbl.addEventListener('click', () => {
  851. const open = camList.classList.toggle('open');
  852. floorLbl.querySelector('.tr-arrow').classList.toggle('open', open);
  853. });
  854. Object.values(floorData).forEach(lab => {
  855. lab.cams.forEach(cam => camList.appendChild(makeCamItem(cam)));
  856. });
  857. floorDiv.appendChild(floorLbl);
  858. floorDiv.appendChild(camList);
  859. bldFloors.appendChild(floorDiv);
  860. });
  861. bldDiv.appendChild(bldLbl);
  862. bldDiv.appendChild(bldFloors);
  863. tree.appendChild(bldDiv);
  864. });
  865. }
  866. function makeCamItem(cam) {
  867. const el = document.createElement('div');
  868. el.className = 'cam-item';
  869. el.dataset.camId = cam.id;
  870. el.dataset.unit = cam.unit;
  871. el.draggable = true;
  872. const used = isUsed(cam.id);
  873. const badge = used
  874. ? '<span class="cam-badge used">已配置</span>'
  875. : cam.type === 'ai'
  876. ? '<span class="cam-badge ai">AI</span>'
  877. : '';
  878. el.innerHTML = `
  879. <span class="cam-drag">⠿</span>
  880. <div class="cam-dot ${cam.status}"></div>
  881. <span class="cam-name" title="${cam.name}">${cam.name}</span>
  882. ${badge}
  883. `;
  884. if (used) el.style.opacity = '';
  885. el.addEventListener('click', () => onCamClick(cam.id, el));
  886. el.addEventListener('dragstart', e => {
  887. e.dataTransfer.setData('camId', cam.id);
  888. e.dataTransfer.setData('dragType', 'fromTree');
  889. el.classList.add('dragging');
  890. });
  891. el.addEventListener('dragend', () => el.classList.remove('dragging'));
  892. return el;
  893. }
  894. function isUsed(camId) { return slots.some(s => s.camId === camId); }
  895. function onCamClick(camId, el) {
  896. const cam = CAM_DB.find(c => c.id === camId);
  897. if (!cam) return;
  898. document.querySelectorAll('.cam-item.selected-cam').forEach(e => {
  899. e.classList.remove('selected-cam');
  900. e.style.opacity = isUsed(e.dataset.camId) ? '0.55' : '';
  901. });
  902. if (selectedCamId === camId) { selectedCamId = null; return; }
  903. selectedCamId = camId;
  904. el.classList.add('selected-cam');
  905. el.style.opacity = '';
  906. if (selectedSlotPos !== null) {
  907. tryAssign(selectedSlotPos, camId);
  908. clearCamSelection();
  909. } else {
  910. toast(`已选中 ${cam.name},请点击目标槽位`);
  911. }
  912. }
  913. function clearCamSelection() {
  914. document.querySelectorAll('.cam-item.selected-cam').forEach(e => {
  915. e.classList.remove('selected-cam');
  916. e.style.opacity = isUsed(e.dataset.camId) ? '0.55' : '';
  917. });
  918. selectedCamId = null;
  919. }
  920. /* ===================================================
  921. SLOT GRID
  922. =================================================== */
  923. function buildGrid() {
  924. const grid = document.getElementById('slot-grid');
  925. const cols = gridMode === 9 ? 3 : 2;
  926. grid.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
  927. grid.style.gridTemplateRows = `repeat(${cols}, 1fr)`;
  928. grid.innerHTML = '';
  929. slots.forEach(slot => grid.appendChild(makeSlotEl(slot)));
  930. }
  931. function makeSlotEl(slot) {
  932. const cam = slot.camId ? CAM_DB.find(c => c.id === slot.camId) : null;
  933. const el = document.createElement('div');
  934. el.dataset.pos = slot.pos;
  935. let cls = 'slot';
  936. if (!cam) cls += ' empty';
  937. if (slot.aiRequired && cam) {
  938. cls += cam.type === 'ai' ? ' slot1-required' : ' slot1-warn';
  939. } else if (slot.aiRequired) {
  940. cls += ' slot1-warn';
  941. }
  942. el.className = cls;
  943. if (selectedSlotPos === slot.pos) el.classList.add('selected');
  944. renderSlotContent(el, slot, cam);
  945. attachSlotHandlers(el, slot.pos);
  946. return el;
  947. }
  948. function renderSlotContent(el, slot, cam) {
  949. const numBadge = `<div class="slot-num">${slot.pos}</div>`;
  950. if (cam) {
  951. const aiTag = cam.type === 'ai'
  952. ? `<div class="slot-ai-tag">🤖 AI</div>`
  953. : slot.aiRequired ? `<div class="slot-ai-tag req-warn">⚠ 须AI</div>` : '';
  954. const recTag = cam.status === 'online'
  955. ? `<div class="slot-rec">REC</div>`
  956. : `<div class="slot-offline-tag">📡 离线</div>`;
  957. const aiBox = (slot.pos === 1 && cam.type === 'ai')
  958. ? `<div class="ai-det-box" style="left:27%;top:16%;width:22%;height:40%"><div class="ai-det-lbl">危险行为检测</div></div>`
  959. : '';
  960. const hoverActions = `
  961. <div class="slot-hover-actions">
  962. <button class="slot-act-btn" onclick="event.stopPropagation();selectSlot(${slot.pos});toast('请从左侧选择新摄像头替换')">替换</button>
  963. <button class="slot-act-btn del" onclick="event.stopPropagation();doClearSlot(${slot.pos})">清除</button>
  964. </div>`;
  965. el.innerHTML = `
  966. ${numBadge}${aiTag}
  967. <canvas></canvas>
  968. <div class="slot-voverlay"></div>
  969. ${recTag}
  970. <div class="slot-cam-name">${cam.name}</div>
  971. <div class="slot-cam-loc">${cam.code} · ${cam.floor}</div>
  972. ${aiBox}${hoverActions}
  973. `;
  974. const canvas = el.querySelector('canvas');
  975. if (canvas && cam.status === 'online') setTimeout(() => drawFakeCam(canvas, slot.pos - 1), 80);
  976. } else {
  977. el.innerHTML = `
  978. ${numBadge}
  979. ${slot.aiRequired ? '<div class="slot-ai-tag req-warn">⚠ 须AI</div>' : ''}
  980. <div class="slot-empty">
  981. <div class="slot-empty-icon">${slot.aiRequired ? '🤖' : '📷'}</div>
  982. <div class="slot-empty-text">${slot.aiRequired ? '请分配 AI 摄像头' : '点击选择摄像头'}</div>
  983. <div class="slot-empty-hint">或从左侧拖拽至此</div>
  984. </div>
  985. `;
  986. }
  987. }
  988. function attachSlotHandlers(el, pos) {
  989. el.addEventListener('click', () => {
  990. if (selectedCamId) { tryAssign(pos, selectedCamId); clearCamSelection(); }
  991. else selectSlot(pos);
  992. });
  993. el.addEventListener('dragstart', e => {
  994. const slot = slots[pos - 1];
  995. if (!slot.camId) { e.preventDefault(); return; }
  996. e.dataTransfer.setData('dragType', 'fromSlot');
  997. e.dataTransfer.setData('fromPos', String(pos));
  998. e.dataTransfer.setData('camId', slot.camId);
  999. });
  1000. el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); });
  1001. el.addEventListener('dragleave', () => el.classList.remove('drag-over'));
  1002. el.addEventListener('drop', e => {
  1003. e.preventDefault(); e.stopPropagation();
  1004. el.classList.remove('drag-over');
  1005. const dragType = e.dataTransfer.getData('dragType');
  1006. const camId = e.dataTransfer.getData('camId');
  1007. if (dragType === 'fromSlot') {
  1008. const fromPos = parseInt(e.dataTransfer.getData('fromPos'));
  1009. if (fromPos !== pos) doSwapSlots(fromPos, pos);
  1010. } else if (dragType === 'fromTree' && camId) {
  1011. tryAssign(pos, camId);
  1012. }
  1013. });
  1014. }
  1015. function selectSlot(pos) {
  1016. selectedSlotPos = pos;
  1017. document.querySelectorAll('.slot').forEach(el => el.classList.remove('selected'));
  1018. const el = document.querySelector(`.slot[data-pos="${pos}"]`);
  1019. if (el) el.classList.add('selected');
  1020. if (selectedCamId) toast('请点击槽位完成分配');
  1021. }
  1022. function tryAssign(pos, camId) {
  1023. const slot = slots[pos - 1];
  1024. const cam = CAM_DB.find(c => c.id === camId);
  1025. if (!cam) return;
  1026. if (slot.aiRequired && cam.type !== 'ai') { toast('⚠ 槽位1须分配 AI 智能摄像头', 'warn'); return; }
  1027. const prev = slots.find(s => s.camId === camId && s.pos !== pos);
  1028. if (prev) { prev.camId = null; refreshSlot(prev.pos); }
  1029. slot.camId = camId;
  1030. refreshSlot(pos);
  1031. selectSlot(pos);
  1032. rebuildTree();
  1033. updateStats();
  1034. toast(`✓ ${cam.name} → 槽位 ${pos}`, 'success');
  1035. }
  1036. function doClearSlot(pos) {
  1037. slots[pos - 1].camId = null;
  1038. refreshSlot(pos); rebuildTree(); updateStats();
  1039. toast('槽位已清空');
  1040. }
  1041. function doSwapSlots(p1, p2) {
  1042. const tmp = slots[p1-1].camId;
  1043. slots[p1-1].camId = slots[p2-1].camId;
  1044. slots[p2-1].camId = tmp;
  1045. const s1 = slots[0];
  1046. if (s1.aiRequired && s1.camId) {
  1047. const c = CAM_DB.find(c => c.id === s1.camId);
  1048. if (c && c.type !== 'ai') {
  1049. slots[p2-1].camId = slots[p1-1].camId;
  1050. slots[p1-1].camId = tmp;
  1051. toast('⚠ 槽位1须保留AI摄像头,无法与普通摄像头互换', 'warn');
  1052. return;
  1053. }
  1054. }
  1055. refreshSlot(p1); refreshSlot(p2); updateStats();
  1056. toast('槽位已互换');
  1057. }
  1058. function refreshSlot(pos) {
  1059. const slot = slots[pos - 1];
  1060. const oldEl = document.querySelector(`.slot[data-pos="${pos}"]`);
  1061. if (!oldEl) return;
  1062. const newEl = makeSlotEl(slot);
  1063. if (selectedSlotPos === pos) newEl.classList.add('selected');
  1064. oldEl.replaceWith(newEl);
  1065. }
  1066. function rebuildTree() {
  1067. buildTree();
  1068. if (selectedCamId) {
  1069. const el = document.querySelector(`.cam-item[data-cam-id="${selectedCamId}"]`);
  1070. if (el) el.classList.add('selected-cam');
  1071. }
  1072. }
  1073. /* ===================================================
  1074. STATS
  1075. =================================================== */
  1076. function updateStats() {
  1077. const total = slots.length;
  1078. const configured = slots.filter(s => s.camId).length;
  1079. const empty = total - configured;
  1080. const s1Cam = slots[0].camId ? CAM_DB.find(c => c.id === slots[0].camId) : null;
  1081. const aiOk = s1Cam?.type === 'ai';
  1082. const assigned = slots.filter(s => s.camId).map(s => CAM_DB.find(c => c.id === s.camId)).filter(Boolean);
  1083. const onlinePct = configured > 0 ? Math.round(assigned.filter(c => c.status === 'online').length / configured * 100) : 0;
  1084. document.getElementById('st-configured').textContent = `${configured} / ${total}`;
  1085. document.getElementById('st-configured').className = `sb-val ${configured === total ? 'ok' : 'warn'}`;
  1086. document.getElementById('st-empty').textContent = empty;
  1087. document.getElementById('st-empty').className = `sb-val ${empty === 0 ? 'ok' : 'warn'}`;
  1088. document.getElementById('st-ai').textContent = s1Cam ? (aiOk ? '✓ 已满足' : '✗ 非AI') : '未配置';
  1089. document.getElementById('st-ai').className = `sb-val ${aiOk ? 'ok' : 'err'}`;
  1090. document.getElementById('st-online-rate').textContent = configured > 0 ? `${onlinePct}%` : '--';
  1091. const badge = document.getElementById('ai-badge');
  1092. badge.className = `sb-constraint ${aiOk ? 'ok' : 'err'}`;
  1093. badge.textContent = aiOk ? '✓ 槽位约束已满足' : '⚠ 槽位1需AI摄像头';
  1094. document.getElementById('bb-validity').textContent = aiOk
  1095. ? (configured === total ? '✓ 配置完整有效' : `⚠ 待配置 ${empty} 个槽位`)
  1096. : '✗ 槽位1 AI约束未满足';
  1097. }
  1098. function updateCamCounts() {
  1099. const online = CAM_DB.filter(c => c.status === 'online').length;
  1100. const offline = CAM_DB.length - online;
  1101. document.getElementById('cnt-online').textContent = online;
  1102. document.getElementById('cnt-offline').textContent = offline;
  1103. document.getElementById('cnt-total').textContent = CAM_DB.length;
  1104. document.getElementById('cnt-total-badge').textContent = CAM_DB.length + ' 台';
  1105. }
  1106. /* ===================================================
  1107. QUICK OPS
  1108. =================================================== */
  1109. function autoFill() {
  1110. const total = slots.length;
  1111. const aiCams = CAM_DB.filter(c => c.type === 'ai' && c.status === 'online');
  1112. const onlineCams = CAM_DB.filter(c => c.status === 'online');
  1113. if (!aiCams.length) { toast('没有在线 AI 摄像头', 'warn'); return; }
  1114. slots[0].camId = aiCams[0].id;
  1115. let used = new Set([aiCams[0].id]);
  1116. let oi = 0;
  1117. for (let i = 1; i < total; i++) {
  1118. while (oi < onlineCams.length && used.has(onlineCams[oi].id)) oi++;
  1119. slots[i].camId = oi < onlineCams.length ? (used.add(onlineCams[oi].id), onlineCams[oi++].id) : null;
  1120. }
  1121. buildGrid(); rebuildTree(); updateStats();
  1122. toast('✨ 智能自动填充完成', 'success');
  1123. }
  1124. function clearAll() {
  1125. slots.forEach(s => s.camId = null);
  1126. buildGrid(); rebuildTree(); updateStats();
  1127. toast('已清空全部槽位');
  1128. }
  1129. function loadLastSave() {
  1130. const snap = gridMode === 9 ? savedSnapshot9 : savedSnapshot4;
  1131. snap.forEach((id, i) => { if (slots[i]) slots[i].camId = id || null; });
  1132. buildGrid(); rebuildTree(); updateStats();
  1133. toast('↩ 已恢复至上次保存的配置', 'success');
  1134. }
  1135. function resetConfig() { loadLastSave(); }
  1136. /* ===================================================
  1137. GRID MODE SWITCH
  1138. =================================================== */
  1139. function switchGridMode(mode) {
  1140. if (gridMode === mode) return;
  1141. gridMode = mode;
  1142. slots = mode === 9 ? slots9 : slots4;
  1143. // Update tab active state
  1144. document.getElementById('tab-btn-9').classList.toggle('active', mode === 9);
  1145. document.getElementById('tab-btn-4').classList.toggle('active', mode === 4);
  1146. // Update card title
  1147. const titleEl = document.querySelector('.card-title-text');
  1148. if (titleEl) titleEl.textContent = mode === 9 ? '安全大厦监控配置' : '基地监控配置';
  1149. selectedSlotPos = null;
  1150. selectedCamId = null;
  1151. buildGrid(); rebuildTree(); updateStats();
  1152. toast(`已切换至 ${mode === 9 ? '安全大厦(9格)' : '基地(4格)'} 配置`);
  1153. }
  1154. /* ===================================================
  1155. FILTER
  1156. =================================================== */
  1157. function filterCams(kw) {
  1158. const q = kw.toLowerCase().trim();
  1159. document.querySelectorAll('.cam-item').forEach(el => {
  1160. el.style.display = (!q || el.textContent.toLowerCase().includes(q)) ? '' : 'none';
  1161. });
  1162. }
  1163. function filterUnit(unit) {
  1164. document.querySelectorAll('.tr-bld').forEach(el => {
  1165. el.style.display = (!unit || el.dataset.unit === unit) ? '' : 'none';
  1166. });
  1167. }
  1168. /* ===================================================
  1169. SAVE
  1170. =================================================== */
  1171. let historyLog = [];
  1172. function doSave() {
  1173. const apply = document.getElementById('apply-toggle').checked;
  1174. const s1Cam = slots[0].camId ? CAM_DB.find(c => c.id === slots[0].camId) : null;
  1175. if (slots[0].camId && s1Cam?.type !== 'ai') {
  1176. toast('⚠ 槽位1须为 AI 摄像头,请检查配置', 'warn'); return;
  1177. }
  1178. if (gridMode === 9) savedSnapshot9 = slots.map(s => s.camId);
  1179. else savedSnapshot4 = slots.map(s => s.camId);
  1180. const now = new Date();
  1181. historyLog.unshift({ time: now.toLocaleTimeString('zh-CN'), op: apply ? '保存并生效' : '保存草稿' });
  1182. document.getElementById('bb-save-text').textContent = `上次保存: ${now.toLocaleTimeString('zh-CN')}`;
  1183. toast(apply ? '✓ 已保存并生效,大屏即时更新' : '✓ 草稿已保存', 'success');
  1184. }
  1185. /* ===================================================
  1186. PREVIEW
  1187. =================================================== */
  1188. function openPreview() {
  1189. const modal = document.getElementById('preview-modal');
  1190. const grid = document.getElementById('prev-grid');
  1191. grid.innerHTML = '';
  1192. slots.forEach((slot, i) => {
  1193. const cam = slot.camId ? CAM_DB.find(c => c.id === slot.camId) : null;
  1194. const cell = document.createElement('div');
  1195. cell.className = 'prev-cell';
  1196. if (cam && cam.status === 'online') {
  1197. cell.innerHTML = `
  1198. <canvas></canvas>
  1199. <div class="prev-cell-ov"></div>
  1200. <div class="prev-cell-num">${slot.pos}</div>
  1201. <div class="prev-cell-name">${cam.name}</div>
  1202. ${cam.type === 'ai' ? '<div class="prev-ai-tag">🤖 AI</div>' : ''}
  1203. `;
  1204. setTimeout(() => { const cv = cell.querySelector('canvas'); if (cv) drawFakeCam(cv, i); }, 80);
  1205. } else {
  1206. cell.innerHTML = `
  1207. <div class="prev-cell-num">${slot.pos}</div>
  1208. <div style="color:rgba(255,255,255,0.2);font-size:12px">${cam ? '📡 离线' : '空槽位'}</div>
  1209. `;
  1210. }
  1211. grid.appendChild(cell);
  1212. });
  1213. modal.classList.add('show');
  1214. }
  1215. function closePreview() {
  1216. document.getElementById('preview-modal').classList.remove('show');
  1217. }
  1218. /* ===================================================
  1219. FAKE CAMERA CANVAS
  1220. =================================================== */
  1221. function drawFakeCam(canvas, idx) {
  1222. const ctx = canvas.getContext('2d');
  1223. const par = canvas.parentElement;
  1224. let pw, ph;
  1225. function sz() {
  1226. pw = canvas.width = par.offsetWidth || canvas.clientWidth || 320;
  1227. ph = canvas.height = par.offsetHeight || canvas.clientHeight || 180;
  1228. }
  1229. sz();
  1230. let t = Math.random() * 1000;
  1231. const noise = Array.from({length:60}, () => ({ x:Math.random(), y:Math.random(), s:Math.random()*2, a:Math.random()*0.2 }));
  1232. function frame() {
  1233. if (!canvas.isConnected) return;
  1234. t += 0.5;
  1235. if (!pw || !ph) sz();
  1236. ctx.clearRect(0, 0, pw, ph);
  1237. const bg = ctx.createLinearGradient(0, 0, 0, ph);
  1238. bg.addColorStop(0, 'hsl(210,42%,5%)');
  1239. bg.addColorStop(1, 'hsl(220,30%,3%)');
  1240. ctx.fillStyle = bg; ctx.fillRect(0, 0, pw, ph);
  1241. ctx.strokeStyle = 'rgba(20,70,150,0.06)'; ctx.lineWidth = 0.5;
  1242. const vp = { x: pw/2, y: ph*0.45 };
  1243. for (let gx = 0; gx <= 8; gx++) {
  1244. const fx = pw*gx/8;
  1245. ctx.beginPath(); ctx.moveTo(fx, ph); ctx.lineTo(vp.x+(fx-pw/2)*0.3, vp.y); ctx.stroke();
  1246. }
  1247. for (let gy = 0; gy <= 4; gy++) {
  1248. const fy = ph*0.45+ph*0.55*(gy/4);
  1249. const wd = pw*(0.3+0.7*(gy/4));
  1250. ctx.beginPath(); ctx.moveTo((pw-wd)/2, fy); ctx.lineTo((pw+wd)/2, fy); ctx.stroke();
  1251. }
  1252. ctx.fillStyle = 'rgba(0,35,90,0.35)';
  1253. ctx.fillRect(pw*0.08, ph*0.56, pw*0.38, ph*0.07);
  1254. ctx.fillRect(pw*0.54, ph*0.56, pw*0.38, ph*0.07);
  1255. ctx.fillStyle = 'rgba(0,18,55,0.45)';
  1256. ctx.fillRect(pw*0.12, ph*0.43, pw*0.07, ph*0.13);
  1257. ctx.fillRect(pw*0.58, ph*0.41, pw*0.09, ph*0.15);
  1258. const np = [2,1,1,0,2,1,0,1,2][idx%9] || 1;
  1259. for (let p = 0; p < np; p++) {
  1260. const px = pw*(0.28+p*0.2+Math.sin(t*0.007+p)*0.012);
  1261. const py = ph*(0.62+Math.cos(t*0.005+p)*0.012);
  1262. ctx.fillStyle = 'rgba(120,170,255,0.55)';
  1263. ctx.beginPath(); ctx.arc(px, py, pw*0.018, 0, Math.PI*2); ctx.fill();
  1264. ctx.fillStyle = 'rgba(70,130,220,0.32)';
  1265. ctx.fillRect(px-pw*0.012, py+pw*0.018, pw*0.025, ph*0.055);
  1266. }
  1267. const sl = (t*0.35)%ph;
  1268. ctx.fillStyle = 'rgba(30,144,255,0.035)'; ctx.fillRect(0, sl, pw, 1.5);
  1269. noise.forEach(n => {
  1270. ctx.fillStyle = `rgba(150,195,255,${n.a*0.35})`;
  1271. ctx.fillRect(n.x*pw, n.y*ph, n.s, n.s);
  1272. if (Math.random()<0.015) { n.x=Math.random(); n.y=Math.random(); }
  1273. });
  1274. requestAnimationFrame(frame);
  1275. }
  1276. frame();
  1277. }
  1278. /* ===================================================
  1279. TOAST
  1280. =================================================== */
  1281. function toast(msg, type = 'info') {
  1282. const el = document.getElementById('toast');
  1283. el.textContent = msg;
  1284. el.className = 'toast show ' + (type === 'success' ? 'success' : type === 'warn' ? 'warn' : type === 'err' ? 'err' : '');
  1285. clearTimeout(el._t);
  1286. el._t = setTimeout(() => el.classList.remove('show'), 2800);
  1287. }
  1288. </script>
  1289. </body>
  1290. </html>