| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>固定9宫格摄像头配置 · 管理后台</title>
- <style>
- /* ===== THEME ===== */
- :root {
- --primary: #1890ff;
- --primary-hover: #40a9ff;
- --primary-light: #e6f7ff;
- --primary-border: #91d5ff;
- --success: #52c41a;
- --warning: #faad14;
- --error: #ff4d4f;
- --text-1: #262626;
- --text-2: #595959;
- --text-3: #8c8c8c;
- --text-4: #bfbfbf;
- --bg-page: #f5f7fa;
- --bg-white: #ffffff;
- --bg-gray: #f8f9fa;
- --bg-hover: #f0f5ff;
- --border: #e8e8e8;
- --border-dark: #d9d9d9;
- --shadow-sm: 0 1px 4px rgba(0,0,0,0.06);
- --shadow: 0 2px 8px rgba(0,0,0,0.09);
- --shadow-md: 0 4px 16px rgba(0,0,0,0.1);
- --font: -apple-system,'PingFang SC','Microsoft YaHei','Segoe UI',sans-serif;
- --radius-sm: 4px;
- --radius: 8px;
- --radius-lg: 12px;
- --cam-bg: #040d1e;
- }
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- html, body {
- width: 100%; height: 100%; overflow: hidden;
- background: #e5e9f0; /* 缩放留白区背景 */
- font-family: var(--font); color: var(--text-1); font-size: 14px;
- -webkit-font-smoothing: antialiased;
- }
- /* ===== SCALE WRAPPER ===== */
- #scale-root {
- width: 1920px; height: 1080px;
- position: absolute; top: 0; left: 0;
- transform-origin: top left;
- background: var(--bg-page);
- overflow: hidden;
- }
- ::-webkit-scrollbar { width: 6px; height: 6px; }
- ::-webkit-scrollbar-track { background: var(--bg-gray); }
- ::-webkit-scrollbar-thumb { background: var(--border-dark); border-radius: 3px; }
- ::-webkit-scrollbar-thumb:hover { background: #aaa; }
- /* ===== APP SHELL ===== */
- .app { display: flex; flex-direction: column; width: 1920px; height: 1080px; }
- /* ===== TOP BAR ===== */
- .topbar {
- flex-shrink: 0; height: 56px;
- display: flex; align-items: center; gap: 0; padding: 0 20px;
- background: var(--bg-white);
- border-bottom: 1px solid var(--border);
- box-shadow: var(--shadow-sm); z-index: 100;
- }
- .topbar-logo {
- display: flex; align-items: center; gap: 10px;
- padding-right: 20px; border-right: 1px solid var(--border);
- flex-shrink: 0; text-decoration: none;
- }
- .logo-icon {
- width: 32px; height: 32px; border-radius: 50%;
- background: linear-gradient(135deg, #096dd9, #1890ff);
- display: flex; align-items: center; justify-content: center;
- font-size: 14px; font-weight: 700; color: #fff; flex-shrink: 0;
- }
- .logo-text { font-size: 15px; font-weight: 600; color: var(--text-1); letter-spacing: 0.3px; }
- .logo-sub { font-size: 11px; color: var(--text-3); }
- .topbar-nav {
- display: flex; align-items: stretch; height: 100%;
- padding-left: 8px; flex: 1; gap: 0;
- }
- .tn-item {
- display: flex; align-items: center; gap: 6px; padding: 0 16px;
- font-size: 13px; color: var(--text-2); cursor: pointer;
- border-bottom: 2px solid transparent; transition: all 0.2s;
- text-decoration: none; white-space: nowrap;
- }
- .tn-item:hover { color: var(--primary); }
- .tn-item.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 500; }
- .topbar-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
- .tb-icon-btn {
- width: 36px; height: 36px;
- display: flex; align-items: center; justify-content: center;
- border-radius: var(--radius-sm); cursor: pointer; color: var(--text-3);
- font-size: 16px; transition: background 0.15s, color 0.15s;
- border: none; background: none;
- }
- .tb-icon-btn:hover { background: var(--bg-hover); color: var(--primary); }
- .tb-divider { width: 1px; height: 22px; background: var(--border); margin: 0 8px; }
- .tb-back-btn {
- display: flex; align-items: center; gap: 5px;
- padding: 5px 12px; margin-left: 12px;
- border-radius: var(--radius-sm); border: 1px solid var(--border-dark);
- background: var(--bg-white); color: var(--text-2);
- font-size: 13px; font-family: var(--font); cursor: pointer;
- transition: all 0.15s; flex-shrink: 0;
- }
- .tb-back-btn:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-light); }
- .topbar-user {
- display: flex; align-items: center; gap: 8px; padding: 5px 10px;
- border-radius: var(--radius-sm); cursor: pointer; transition: background 0.15s;
- }
- .topbar-user:hover { background: var(--bg-hover); }
- .user-avatar {
- width: 28px; height: 28px; border-radius: 50%;
- background: linear-gradient(135deg, #1890ff, #096dd9);
- display: flex; align-items: center; justify-content: center;
- font-size: 12px; color: #fff; font-weight: 600; flex-shrink: 0;
- }
- .user-name { font-size: 13px; color: var(--text-2); font-weight: 500; }
- .user-role { font-size: 11px; color: var(--text-4); }
- /* ===== MAIN AREA ===== */
- .main { display: flex; flex: 1; min-height: 0; }
- /* ===== LEFT NAV MENU ===== */
- .left-nav {
- width: 282px; flex-shrink: 0;
- display: flex; flex-direction: column;
- background: var(--bg-white);
- border-right: 1px solid var(--border);
- overflow-y: auto; overflow-x: hidden;
- user-select: none;
- }
- .ln-section {
- padding: 18px 0 4px;
- }
- .ln-section + .ln-section { border-top: 1px solid var(--border); }
- .ln-section-title {
- padding: 0 16px 6px;
- font-size: 11px; font-weight: 500; color: var(--text-4);
- letter-spacing: 0.5px; text-transform: uppercase;
- }
- .ln-item {
- display: flex; align-items: center; gap: 9px;
- padding: 8px 16px; font-size: 13px; color: var(--text-2);
- cursor: pointer; transition: all 0.15s;
- border-left: 3px solid transparent;
- position: relative;
- }
- .ln-item:hover { background: var(--bg-hover); color: var(--text-1); }
- .ln-item.active {
- background: var(--primary-light);
- color: var(--primary);
- border-left-color: var(--primary);
- font-weight: 500;
- }
- .ln-item-icon { font-size: 14px; flex-shrink: 0; }
- .ln-item-badge {
- margin-left: auto; font-size: 10px; padding: 1px 6px;
- border-radius: 10px; background: var(--bg-gray);
- color: var(--text-4); border: 1px solid var(--border);
- flex-shrink: 0;
- }
- .ln-item.active .ln-item-badge {
- background: rgba(24,144,255,0.12); color: var(--primary);
- border-color: var(--primary-border);
- }
- /* ===== CAMERA TREE SIDEBAR ===== */
- .sidebar {
- width: 322px; flex-shrink: 0;
- display: flex; flex-direction: column;
- background: var(--bg-white);
- border-right: 1px solid var(--border);
- overflow: hidden;
- }
- .sb-hdr { padding: 14px 16px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
- .sb-title {
- font-size: 13px; font-weight: 600; color: var(--text-1);
- display: flex; align-items: center; justify-content: space-between;
- margin-bottom: 10px;
- }
- .sb-title-badge {
- font-size: 11px; color: var(--text-3); background: var(--bg-gray);
- padding: 1px 7px; border-radius: 10px; border: 1px solid var(--border); font-weight: 400;
- }
- .sb-stats { display: flex; gap: 6px; }
- .ss-chip {
- flex: 1; padding: 6px 8px; border-radius: var(--radius-sm);
- font-size: 10px; text-align: center;
- background: var(--bg-gray); border: 1px solid var(--border);
- }
- .ss-chip .sv { font-size: 17px; font-weight: 700; line-height: 1.2; }
- .ss-chip.online .sv { color: var(--success); }
- .ss-chip.offline .sv { color: var(--error); }
- .ss-chip.total .sv { color: var(--primary); }
- .ss-chip .sl { color: var(--text-4); font-size: 10px; margin-top: 1px; }
- .sb-search { padding: 10px 14px 8px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
- .search-inp {
- width: 100%; padding: 6px 10px 6px 32px;
- border-radius: var(--radius-sm); background: var(--bg-gray);
- border: 1px solid var(--border); color: var(--text-1);
- font-size: 12px; font-family: var(--font); outline: none; transition: all 0.2s;
- 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");
- background-repeat: no-repeat; background-position: 9px center;
- }
- .search-inp:focus { border-color: var(--primary); background-color: #fff; box-shadow: 0 0 0 2px var(--primary-light); }
- .search-inp::placeholder { color: var(--text-4); }
- .sb-filter { padding: 6px 14px 8px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
- .filter-sel {
- width: 100%; padding: 5px 28px 5px 10px;
- border-radius: var(--radius-sm); background: var(--bg-white);
- border: 1px solid var(--border-dark); color: var(--text-2); font-size: 12px;
- font-family: var(--font); outline: none; cursor: pointer; appearance: none;
- 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");
- background-repeat: no-repeat; background-position: right 10px center; transition: border-color 0.2s;
- }
- .filter-sel:focus { border-color: var(--primary); box-shadow: 0 0 0 2px var(--primary-light); }
- .sb-tree { flex: 1; overflow-y: auto; padding: 6px 10px; }
- /* Tree */
- .tr-bld { margin-bottom: 2px; }
- .tr-bld-lbl {
- display: flex; align-items: center; gap: 6px; padding: 6px 8px;
- border-radius: var(--radius-sm); font-size: 12px; font-weight: 600; color: var(--text-1);
- cursor: pointer; transition: background 0.15s; user-select: none;
- }
- .tr-bld-lbl:hover { background: var(--bg-hover); }
- .tr-arrow { font-size: 9px; color: var(--text-4); transition: transform 0.2s; flex-shrink: 0; }
- .tr-arrow.open { transform: rotate(90deg); }
- .tr-floors { padding-left: 14px; display: none; }
- .tr-floors.open { display: block; }
- .tr-floor-lbl {
- display: flex; align-items: center; gap: 6px; padding: 4px 8px;
- border-radius: var(--radius-sm); font-size: 11px; color: var(--text-3);
- cursor: pointer; user-select: none; transition: background 0.15s;
- }
- .tr-floor-lbl:hover { background: var(--bg-hover); color: var(--text-2); }
- .tr-cams { padding-left: 14px; display: none; }
- .tr-cams.open { display: block; }
- /* Camera item */
- .cam-item {
- display: flex; align-items: center; gap: 6px; padding: 5px 8px;
- border-radius: var(--radius-sm); font-size: 12px; color: var(--text-2);
- cursor: pointer; margin-bottom: 1px; border: 1px solid transparent;
- transition: all 0.15s; user-select: none;
- }
- .cam-item:hover { background: var(--bg-hover); border-color: var(--primary-border); }
- .cam-item.selected-cam { background: var(--primary-light); border-color: var(--primary-border); color: var(--primary); }
- .cam-item.dragging { opacity: 0.4; }
- .cam-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
- .cam-dot.online { background: var(--success); }
- .cam-dot.offline { background: var(--error); }
- .cam-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
- .cam-badge { font-size: 10px; padding: 1px 0; border-radius: 3px; flex-shrink: 0; white-space: nowrap; min-width: 38px; text-align: center; }
- .cam-badge.ai { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
- .cam-badge.normal { background: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; }
- .cam-badge.used { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
- .cam-drag { color: var(--text-4); font-size: 12px; cursor: grab; flex-shrink: 0; }
- .cam-drag:active { cursor: grabbing; }
- /* ===== CENTER CONTENT ===== */
- .content {
- flex: 1; min-width: 0; display: flex; flex-direction: column;
- padding: 0; overflow: hidden; background: var(--bg-page);
- }
- .content-topbar {
- display: flex; align-items: center; padding: 10px 12px 0 8px;
- flex-shrink: 0; gap: 12px;
- }
- .breadcrumb { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-3); flex: 1; }
- .breadcrumb a { color: var(--text-3); text-decoration: none; cursor: pointer; transition: color 0.15s; }
- .breadcrumb a:hover { color: var(--primary); }
- .breadcrumb .sep { color: var(--text-4); font-size: 11px; }
- .breadcrumb .current { color: var(--text-1); font-weight: 500; }
- /* View Tabs */
- .view-tabs {
- display: flex; align-items: center; gap: 0;
- border: 1px solid var(--border-dark); border-radius: var(--radius-sm); overflow: hidden;
- flex-shrink: 0;
- }
- .view-tab {
- padding: 4px 16px; font-size: 13px; font-family: var(--font);
- background: var(--bg-white); color: var(--text-2);
- border: none; cursor: pointer; transition: background 0.15s, color 0.15s;
- border-right: 1px solid var(--border-dark); white-space: nowrap;
- }
- .view-tab:last-child { border-right: none; }
- .view-tab:hover { background: var(--bg-hover); color: var(--primary); }
- .view-tab.active { background: var(--primary); color: #fff; font-weight: 500; }
- .content-body {
- padding: 8px 12px 12px 8px; display: flex; flex-direction: column;
- flex: 1; min-height: 0; gap: 0; overflow: hidden;
- }
- /* Config card */
- .config-card {
- background: var(--bg-white); border-radius: var(--radius);
- border: 1px solid var(--border); box-shadow: var(--shadow-sm);
- display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden;
- }
- .card-header {
- display: flex; align-items: center; justify-content: space-between;
- padding: 11px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0;
- gap: 12px;
- }
- .card-title-wrap { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
- .card-title-icon { font-size: 17px; }
- .card-title-text { font-size: 14px; font-weight: 600; color: var(--text-1); white-space: nowrap; }
- .card-title-sub { font-size: 12px; color: var(--text-3); white-space: nowrap; }
- .card-title-divider { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }
- /* Quick ops in header */
- .header-qops { display: flex; align-items: center; gap: 6px; }
- .qop-btn-sm {
- display: inline-flex; align-items: center; gap: 5px;
- padding: 5px 12px; border-radius: var(--radius-sm);
- font-size: 12px; cursor: pointer; font-family: var(--font);
- border: 1px solid var(--border-dark); background: var(--bg-white);
- color: var(--text-2); transition: all 0.15s; white-space: nowrap;
- }
- .qop-btn-sm:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-light); }
- .qop-btn-sm.danger:hover { border-color: var(--error); color: var(--error); background: #fff2f0; }
- .live-tag { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--success); flex-shrink: 0; }
- .live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); flex-shrink: 0; }
- .hint-tag {
- display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px;
- border-radius: var(--radius-sm); background: #fffbe6; border: 1px solid #ffe58f;
- font-size: 11px; color: #d48806; white-space: nowrap;
- }
- /* 9-Slot Grid */
- .slot-grid {
- display: grid;
- gap: 10px; flex: 1; min-height: 0; padding: 14px;
- }
- .slot {
- position: relative; border-radius: 6px; overflow: hidden;
- background: var(--cam-bg); border: 2px solid #dde3ed; cursor: pointer;
- transition: border-color 0.2s, box-shadow 0.2s;
- display: flex; align-items: center; justify-content: center; min-height: 0;
- }
- .slot:hover:not(.selected) { border-color: var(--primary-border); box-shadow: 0 0 0 2px var(--primary-light); }
- .slot.selected { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(24,144,255,0.15); }
- .slot.slot1-required { border-color: #b7eb8f; }
- .slot.slot1-required.selected { border-color: var(--success); box-shadow: 0 0 0 3px rgba(82,196,26,0.12); }
- .slot.slot1-warn { border-color: #ffd591; }
- .slot.drag-over { border-color: var(--primary); border-style: dashed; background: rgba(24,144,255,0.04); }
- .slot-num {
- position: absolute; top: 6px; left: 6px; z-index: 5;
- min-width: 20px; height: 20px; padding: 0 6px; border-radius: 4px;
- background: rgba(0,0,0,0.55);
- display: flex; align-items: center; justify-content: center;
- font-size: 10px; font-weight: 700; color: rgba(255,255,255,0.9); transition: all 0.2s;
- }
- .slot.selected .slot-num { background: var(--primary); color: #fff; }
- .slot-ai-tag {
- position: absolute; top: 6px; right: 6px; z-index: 5;
- font-size: 9px; padding: 2px 6px; border-radius: 3px;
- background: rgba(0,30,15,0.85); color: #73d13d; border: 1px solid rgba(115,209,61,0.4);
- }
- .slot-ai-tag.req-warn { background: rgba(255,240,200,0.9); color: #d48806; border-color: #ffd591; }
- .slot-empty {
- display: flex; flex-direction: column; align-items: center; gap: 6px;
- user-select: none; pointer-events: none;
- }
- .slot-empty-icon { font-size: 22px; opacity: 0.3; }
- .slot-empty-text { font-size: 11px; color: rgba(255,255,255,0.22); }
- .slot-empty-hint { font-size: 10px; color: rgba(255,255,255,0.13); }
- .slot canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
- .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; }
- .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; }
- .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; }
- .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; }
- .slot-rec::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: #ff4d4f; animation: blinkRed 1s ease-in-out infinite; }
- .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; }
- @keyframes blinkRed { 0%,100%{opacity:1}50%{opacity:0.2} }
- .slot-hover-actions {
- position: absolute; inset: 0; z-index: 10;
- display: flex; align-items: center; justify-content: center; gap: 8px;
- background: rgba(0,0,0,0.55); opacity: 0; transition: opacity 0.15s;
- }
- .slot:hover:not(.empty) .slot-hover-actions { opacity: 1; }
- .slot-act-btn {
- padding: 4px 12px; border-radius: 4px; font-size: 11px; cursor: pointer;
- border: 1px solid rgba(255,255,255,0.35); background: rgba(255,255,255,0.12);
- color: rgba(255,255,255,0.9); transition: all 0.15s; font-family: var(--font);
- backdrop-filter: blur(4px);
- }
- .slot-act-btn:hover { background: rgba(24,144,255,0.5); border-color: #1890ff; }
- .slot-act-btn.del { border-color: rgba(255,77,79,0.5); color: #ff7875; }
- .slot-act-btn.del:hover { background: rgba(255,77,79,0.35); border-color: #ff4d4f; }
- .ai-det-box {
- position: absolute; border: 1.5px solid #ff4d4f; background: rgba(255,77,79,0.07);
- border-radius: 3px; z-index: 6; animation: detBox 1.5s ease-in-out infinite;
- }
- @keyframes detBox { 0%,100%{border-color:#ff4d4f}50%{border-color:#ffa39e} }
- .ai-det-lbl {
- position: absolute; bottom: 100%; left: 0; white-space: nowrap;
- font-size: 8px; background: #ff4d4f; color: #fff; padding: 1px 5px;
- border-radius: 2px; margin-bottom: 2px;
- }
- /* Stats bar */
- .stats-bar {
- display: flex; align-items: center; gap: 14px; flex-shrink: 0;
- padding: 8px 16px; border-top: 1px solid var(--border); background: var(--bg-gray);
- }
- .sb-item { display: flex; align-items: center; gap: 6px; font-size: 12px; white-space: nowrap; }
- .sb-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
- .sb-lbl { color: var(--text-3); }
- .sb-val { font-weight: 600; color: var(--text-1); }
- .sb-val.ok { color: var(--success); }
- .sb-val.warn { color: var(--warning); }
- .sb-val.err { color: var(--error); }
- .sb-sep { width: 1px; height: 16px; background: var(--border); flex-shrink: 0; }
- .sb-constraint {
- margin-left: auto; display: flex; align-items: center; gap: 5px;
- font-size: 11px; padding: 3px 10px; border-radius: 4px; flex-shrink: 0;
- }
- .sb-constraint.ok { background: #f6ffed; border: 1px solid #b7eb8f; color: var(--success); }
- .sb-constraint.err { background: #fff2f0; border: 1px solid #ffa39e; color: var(--error); animation: blinkRed 1.8s ease-in-out infinite; }
- /* ===== BOTTOM ACTION BAR ===== */
- .bottombar {
- flex-shrink: 0; height: 52px;
- display: flex; align-items: center; justify-content: space-between;
- padding: 0 20px; background: var(--bg-white);
- border-top: 1px solid var(--border);
- box-shadow: 0 -2px 8px rgba(0,0,0,0.05); z-index: 100;
- }
- .bb-info { display: flex; align-items: center; gap: 12px; font-size: 12px; color: var(--text-3); }
- .bb-live { display: flex; align-items: center; gap: 5px; }
- .bb-live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); animation: pulse 2s ease-in-out infinite; }
- @keyframes pulse { 0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.3);opacity:0.7} }
- .bb-sep { width: 1px; height: 14px; background: var(--border); }
- .bb-btns { display: flex; align-items: center; gap: 8px; }
- /* Apply toggle switch */
- .apply-toggle-wrap {
- display: flex; align-items: center; gap: 7px;
- padding: 4px 12px 4px 10px; margin-right: 4px;
- border-radius: var(--radius-sm); background: var(--bg-gray);
- border: 1px solid var(--border);
- }
- .apply-toggle-lbl { font-size: 12px; color: var(--text-2); white-space: nowrap; user-select: none; cursor: pointer; }
- .toggle-switch {
- position: relative; width: 32px; height: 18px; flex-shrink: 0; cursor: pointer;
- }
- .toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
- .toggle-track {
- position: absolute; inset: 0; border-radius: 9px;
- background: var(--border-dark); transition: background 0.2s;
- }
- .toggle-thumb {
- position: absolute; top: 2px; left: 2px;
- width: 14px; height: 14px; border-radius: 50%;
- background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
- transition: transform 0.2s;
- }
- .toggle-switch input:checked ~ .toggle-track { background: var(--primary); }
- .toggle-switch input:checked ~ .toggle-thumb { transform: translateX(14px); }
- .btn {
- padding: 6px 18px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 500;
- cursor: pointer; border: 1px solid var(--border-dark); font-family: var(--font);
- transition: all 0.2s; display: inline-flex; align-items: center; gap: 5px;
- }
- .btn-default { background: var(--bg-white); color: var(--text-2); }
- .btn-default:hover { border-color: var(--primary); color: var(--primary); }
- .btn-outline { background: var(--bg-white); border-color: var(--primary); color: var(--primary); }
- .btn-outline:hover { background: var(--primary-light); }
- .btn-sec { background: var(--bg-white); border-color: var(--primary); color: var(--primary); }
- .btn-sec:hover { background: var(--primary-light); }
- .btn-pri { background: var(--primary); border-color: var(--primary); color: #fff; }
- .btn-pri:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
- .btn-pri:active { transform: scale(0.98); }
- /* ===== TOAST ===== */
- .toast {
- position: fixed; bottom: 64px; left: 50%;
- transform: translateX(-50%) translateY(12px);
- padding: 8px 20px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 500;
- background: rgba(0,0,0,0.75); color: #fff; box-shadow: var(--shadow-md);
- z-index: 9999; opacity: 0; transition: all 0.25s; pointer-events: none;
- }
- .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
- .toast.success { background: #52c41a; }
- .toast.warn { background: #faad14; }
- .toast.err { background: #ff4d4f; }
- /* ===== PREVIEW MODAL ===== */
- #preview-modal {
- position: fixed; inset: 0; z-index: 9998;
- display: none; align-items: center; justify-content: center;
- background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
- }
- #preview-modal.show { display: flex; animation: fadeIn 0.22s ease; }
- @keyframes fadeIn { from{opacity:0;transform:scale(0.97)}to{opacity:1;transform:scale(1)} }
- .prev-inner {
- width: 78vw; max-width: 1100px; border-radius: var(--radius-lg); overflow: hidden;
- background: var(--bg-white); border: 1px solid var(--border);
- box-shadow: 0 20px 60px rgba(0,0,0,0.2);
- }
- .prev-hdr {
- padding: 14px 20px; display: flex; align-items: center; justify-content: space-between;
- border-bottom: 1px solid var(--border); background: var(--bg-gray);
- }
- .prev-title { font-size: 15px; font-weight: 600; color: var(--text-1); }
- .prev-sub { font-size: 12px; color: var(--text-3); margin-top: 2px; }
- .prev-close {
- width: 28px; height: 28px; border-radius: var(--radius-sm); cursor: pointer;
- border: 1px solid var(--border); background: var(--bg-white); color: var(--text-3);
- font-size: 13px; display: flex; align-items: center; justify-content: center; transition: all 0.15s;
- }
- .prev-close:hover { border-color: var(--error); color: var(--error); background: #fff2f0; }
- .prev-body { padding: 16px; background: #1a1a2e; }
- .prev-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; }
- .prev-cell {
- aspect-ratio: 16/9; border-radius: 5px; overflow: hidden;
- background: var(--cam-bg); border: 1px solid rgba(255,255,255,0.08);
- position: relative; display: flex; align-items: center; justify-content: center;
- }
- .prev-cell canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
- .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; }
- .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; }
- .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; }
- .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; }
- .prev-ftr { padding: 12px 20px; display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--border); }
- </style>
- </head>
- <body>
- <div id="scale-root">
- <div class="app">
- <!-- ===== TOP BAR ===== -->
- <div class="topbar">
- <div class="topbar-logo">
- <div class="logo-icon">安</div>
- <div>
- <div class="logo-text">安科院管理后台</div>
- <div class="logo-sub">实验室安全智能监测与管控中心</div>
- </div>
- </div>
- <button class="tb-back-btn" onclick="history.back()" title="返回上一页">
- <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>
- 返回
- </button>
- <nav class="topbar-nav">
- <a class="tn-item" href="#">首页</a>
- <a class="tn-item active" href="#">基础结构管理</a>
- <a class="tn-item" href="#">实验室管理</a>
- <a class="tn-item" href="#">系统配置</a>
- <a class="tn-item" href="#">数据报表</a>
- </nav>
- <div class="topbar-right">
- <button class="tb-icon-btn" title="查看大屏" onclick="window.open('index-v2.html','_blank')">⎋</button>
- <button class="tb-icon-btn" title="系统通知">🔔</button>
- <div class="tb-divider"></div>
- <div class="topbar-user">
- <div class="user-avatar">管</div>
- <div>
- <div class="user-name">系统管理员</div>
- <div class="user-role">超级管理员</div>
- </div>
- </div>
- </div>
- </div>
- <!-- ===== MAIN ===== -->
- <div class="main">
- <!-- ===== LEFT NAV MENU ===== -->
- <div class="left-nav">
- <div class="ln-section">
- <div class="ln-section-title">基础结构管理</div>
- <div class="ln-item" onclick="void 0">
- <span class="ln-item-icon">🖥️</span>实时监控大屏
- </div>
- <div class="ln-item active">
- <span class="ln-item-icon">📺</span>数据可视化配置
- <span class="ln-item-badge">配置</span>
- </div>
- <div class="ln-item" onclick="void 0">
- <span class="ln-item-icon">📷</span>摄像头管理
- <span class="ln-item-badge">21</span>
- </div>
- <div class="ln-item" onclick="void 0">
- <span class="ln-item-icon">🗺️</span>点位地图
- </div>
- </div>
- <div class="ln-section">
- <div class="ln-section-title">实验室管理</div>
- <div class="ln-item" onclick="void 0">
- <span class="ln-item-icon">🔬</span>实验室列表
- <span class="ln-item-badge">128</span>
- </div>
- <div class="ln-item" onclick="void 0">
- <span class="ln-item-icon">📊</span>安全分级
- </div>
- <div class="ln-item" onclick="void 0">
- <span class="ln-item-icon">👥</span>人员管理
- </div>
- <div class="ln-item" onclick="void 0">
- <span class="ln-item-icon">⚗️</span>化学品管理
- </div>
- </div>
- <div class="ln-section">
- <div class="ln-section-title">系统设置</div>
- <div class="ln-item" onclick="void 0">
- <span class="ln-item-icon">👤</span>用户管理
- </div>
- <div class="ln-item" onclick="void 0">
- <span class="ln-item-icon">🔔</span>预警配置
- </div>
- <div class="ln-item" onclick="void 0">
- <span class="ln-item-icon">📋</span>操作日志
- </div>
- </div>
- </div>
- <!-- ===== CAMERA TREE SIDEBAR ===== -->
- <div class="sidebar">
- <div class="sb-hdr">
- <div class="sb-title">
- 摄像头库
- <span class="sb-title-badge" id="cnt-total-badge">-- 台</span>
- </div>
- <div class="sb-stats">
- <div class="ss-chip online">
- <div class="sv" id="cnt-online">--</div>
- <div class="sl">在线</div>
- </div>
- <div class="ss-chip offline">
- <div class="sv" id="cnt-offline">--</div>
- <div class="sl">离线</div>
- </div>
- <div class="ss-chip total">
- <div class="sv" id="cnt-total">--</div>
- <div class="sl">总计</div>
- </div>
- </div>
- </div>
- <div class="sb-search">
- <input class="search-inp" type="text" placeholder="搜索摄像头 / 房间号…" oninput="filterCams(this.value)">
- </div>
- <div class="sb-filter">
- <select class="filter-sel" onchange="filterUnit(this.value)">
- <option value="">全部二级单位</option>
- <option>化学研究所</option>
- <option>物理研究所</option>
- <option>生物研究所</option>
- <option>材料研究所</option>
- <option>工程研究所</option>
- </select>
- </div>
- <div class="sb-tree" id="cam-tree"></div>
- </div>
- <!-- ===== CENTER CONTENT ===== -->
- <div class="content">
- <div class="content-topbar">
- <div class="breadcrumb">
- <a href="#">首页</a>
- <span class="sep">›</span>
- <a href="#">基础结构管理</a>
- <span class="sep">›</span>
- <span class="current">数据可视化配置</span>
- </div>
- <div class="view-tabs">
- <button class="view-tab active" id="tab-btn-9" onclick="switchGridMode(9)">安全大厦</button>
- <button class="view-tab" id="tab-btn-4" onclick="switchGridMode(4)">基地</button>
- </div>
- </div>
- <div class="content-body">
- <div class="config-card">
- <!-- Card Header with quick ops -->
- <div class="card-header">
- <div class="card-title-wrap">
- <span class="card-title-icon">📺</span>
- <span class="card-title-text">安全大厦监控配置</span>
- <span class="card-title-divider"></span>
- <span class="card-title-sub">槽位1须为 AI 摄像头 · 保存后即时生效</span>
- <span class="card-title-divider"></span>
- <div class="live-tag">
- <span class="live-dot"></span>大屏实时同步
- </div>
- </div>
- <div class="header-qops">
- <div class="hint-tag">💡 点选摄像头 → 点击槽位,或直接拖拽</div>
- <button class="qop-btn-sm danger" onclick="clearAll()">🗑 清空全部</button>
- </div>
- </div>
- <div class="slot-grid" id="slot-grid"></div>
- <div class="stats-bar">
- <div class="sb-item">
- <div class="sb-dot" style="background:var(--primary)"></div>
- <span class="sb-lbl">已配置</span>
- <span class="sb-val" id="st-configured">0 / 9</span> </div>
- <div class="sb-sep"></div>
- <div class="sb-item">
- <div class="sb-dot" style="background:var(--success)"></div>
- <span class="sb-lbl">AI槽位</span>
- <span class="sb-val" id="st-ai">--</span>
- </div>
- <div class="sb-sep"></div>
- <div class="sb-item">
- <div class="sb-dot" style="background:var(--warning)"></div>
- <span class="sb-lbl">待配置</span>
- <span class="sb-val" id="st-empty">9</span>
- </div>
- <div class="sb-sep"></div>
- <div class="sb-item">
- <span class="sb-lbl">在线率</span>
- <span class="sb-val" id="st-online-rate">-- %</span>
- </div>
- <div class="sb-constraint" id="ai-badge"></div>
- </div>
- </div>
- </div>
- </div>
- </div><!-- /main -->
- <!-- ===== BOTTOM BAR ===== -->
- <div class="bottombar">
- <div class="bb-info">
- <div class="bb-live">
- <div class="bb-live-dot"></div>
- <span>系统运行正常</span>
- </div>
- <div class="bb-sep"></div>
- <span id="bb-save-text">尚未保存</span>
- <div class="bb-sep"></div>
- <span id="bb-validity"></span>
- </div>
- <div class="bb-btns">
- <label class="apply-toggle-wrap">
- <span class="apply-toggle-lbl">立即生效</span>
- <label class="toggle-switch">
- <input type="checkbox" id="apply-toggle" checked>
- <div class="toggle-track"></div>
- <div class="toggle-thumb"></div>
- </label>
- </label>
- <button class="btn btn-default" onclick="resetConfig()">重置</button>
- <button class="btn btn-outline" onclick="openPreview()">预览效果</button>
- <button class="btn btn-pri" onclick="doSave()">💾 保存</button>
- </div>
- </div>
- </div><!-- /app -->
- </div><!-- /scale-root -->
- <!-- Toast --><div class="toast" id="toast"></div>
- <!-- Preview Modal -->
- <div id="preview-modal">
- <div class="prev-inner">
- <div class="prev-hdr">
- <div>
- <div class="prev-title">📺 大屏「默认」9宫格预览</div>
- <div class="prev-sub">以下为当前配置在大屏中的实际展示效果</div>
- </div>
- <div class="prev-close" onclick="closePreview()">✕</div>
- </div>
- <div class="prev-body">
- <div class="prev-grid" id="prev-grid"></div>
- </div>
- <div class="prev-ftr">
- <button class="btn btn-default" onclick="closePreview()">关闭</button>
- <button class="btn btn-pri" onclick="closePreview();document.getElementById('apply-toggle').checked=true;doSave()">确认并保存生效</button>
- </div>
- </div>
- </div>
- <script>
- /* ===================================================
- DATA
- =================================================== */
- const CAM_DB = [
- {id:'A301-1', name:'A301 有机合成室 #1', code:'A301', labName:'有机合成实验室', floor:'3层', building:'综合实验楼A', unit:'化学研究所', type:'ai', status:'online'},
- {id:'A301-2', name:'A301 有机合成室 #2', code:'A301', labName:'有机合成实验室', floor:'3层', building:'综合实验楼A', unit:'化学研究所', type:'normal', status:'online'},
- {id:'A302-1', name:'A302 核磁共振室 #1', code:'A302', labName:'核磁共振实验室', floor:'3层', building:'综合实验楼A', unit:'化学研究所', type:'ai', status:'online'},
- {id:'A302-2', name:'A302 核磁共振室 #2', code:'A302', labName:'核磁共振实验室', floor:'3层', building:'综合实验楼A', unit:'化学研究所', type:'normal', status:'offline'},
- {id:'A303-1', name:'A303 质谱分析室 #1', code:'A303', labName:'质谱分析实验室', floor:'3层', building:'综合实验楼A', unit:'化学研究所', type:'normal', status:'online'},
- {id:'A303-2', name:'A303 质谱分析室 #2', code:'A303', labName:'质谱分析实验室', floor:'3层', building:'综合实验楼A', unit:'化学研究所', type:'normal', status:'online'},
- {id:'A201-1', name:'A201 化学合成室 #1', code:'A201', labName:'化学合成实验室', floor:'2层', building:'综合实验楼A', unit:'化学研究所', type:'ai', status:'online'},
- {id:'A201-2', name:'A201 化学合成室 #2', code:'A201', labName:'化学合成实验室', floor:'2层', building:'综合实验楼A', unit:'化学研究所', type:'normal', status:'online'},
- {id:'A202-1', name:'A202 样品制备室 #1', code:'A202', labName:'样品制备实验室', floor:'2层', building:'综合实验楼A', unit:'化学研究所', type:'normal', status:'offline'},
- {id:'B401-1', name:'B401 激光实验室 #1', code:'B401', labName:'激光物理实验室', floor:'4层', building:'综合实验楼B', unit:'物理研究所', type:'ai', status:'online'},
- {id:'B401-2', name:'B401 激光实验室 #2', code:'B401', labName:'激光物理实验室', floor:'4层', building:'综合实验楼B', unit:'物理研究所', type:'normal', status:'online'},
- {id:'B402-1', name:'B402 精密光学室 #1', code:'B402', labName:'精密光学实验室', floor:'4层', building:'综合实验楼B', unit:'物理研究所', type:'normal', status:'offline'},
- {id:'B301-1', name:'B301 量子物理室 #1', code:'B301', labName:'量子物理实验室', floor:'3层', building:'综合实验楼B', unit:'物理研究所', type:'ai', status:'online'},
- {id:'B301-2', name:'B301 量子物理室 #2', code:'B301', labName:'量子物理实验室', floor:'3层', building:'综合实验楼B', unit:'物理研究所', type:'normal', status:'online'},
- {id:'B201-1', name:'B201 超导实验室 #1', code:'B201', labName:'超导材料实验室', floor:'2层', building:'综合实验楼B', unit:'物理研究所', type:'normal', status:'online'},
- {id:'C101-1', name:'C101 细胞培养室 #1', code:'C101', labName:'细胞培养实验室', floor:'1层', building:'科研中心楼', unit:'生物研究所', type:'ai', status:'online'},
- {id:'C101-2', name:'C101 细胞培养室 #2', code:'C101', labName:'细胞培养实验室', floor:'1层', building:'科研中心楼', unit:'生物研究所', type:'normal', status:'online'},
- {id:'C201-1', name:'C201 基因检测室 #1', code:'C201', labName:'基因检测实验室', floor:'2层', building:'科研中心楼', unit:'生物研究所', type:'ai', status:'online'},
- {id:'C201-2', name:'C201 基因检测室 #2', code:'C201', labName:'基因检测实验室', floor:'2层', building:'科研中心楼', unit:'生物研究所', type:'normal', status:'offline'},
- {id:'C301-1', name:'C301 材料测试室 #1', code:'C301', labName:'材料测试实验室', floor:'3层', building:'科研中心楼', unit:'材料研究所', type:'ai', status:'online'},
- {id:'C302-1', name:'C302 样品分析室 #1', code:'C302', labName:'样品分析实验室', floor:'3层', building:'科研中心楼', unit:'材料研究所', type:'normal', status:'online'},
- ];
- let slots9 = Array.from({length:9}, (_,i) => ({ pos: i + 1, camId: null, aiRequired: i === 0 }));
- let slots4 = Array.from({length:4}, (_,i) => ({ pos: i + 1, camId: null, aiRequired: i === 0 }));
- const DEFAULT_CONFIG = ['A301-1','A302-1','A303-1','B401-1','B301-1','C101-1','A201-1','B201-1','C301-1'];
- DEFAULT_CONFIG.forEach((id,i) => slots9[i].camId = id);
- let savedSnapshot9 = DEFAULT_CONFIG.slice();
- let savedSnapshot4 = [null,null,null,null];
- let gridMode = 9;
- let slots = slots9;
- let savedSnapshot = savedSnapshot9;
- let selectedSlotPos = null;
- let selectedCamId = null;
- /* ===================================================
- INIT
- =================================================== */
- document.addEventListener('DOMContentLoaded', () => {
- buildTree(); buildGrid(); updateStats(); updateCamCounts();
- fitScreen();
- });
- /* ===================================================
- SCREEN FIT 1920×1080
- =================================================== */
- function fitScreen() {
- const W = 1920, H = 1080;
- const scale = Math.min(window.innerWidth / W, window.innerHeight / H);
- const el = document.getElementById('scale-root');
- el.style.transform = `scale(${scale})`;
- el.style.left = Math.round((window.innerWidth - W * scale) / 2) + 'px';
- el.style.top = Math.round((window.innerHeight - H * scale) / 2) + 'px';
- }
- window.addEventListener('resize', fitScreen);
- /* ===================================================
- CAMERA TREE
- =================================================== */
- function buildTree() {
- const grouped = {};
- CAM_DB.forEach(c => {
- if (!grouped[c.building]) grouped[c.building] = { unit: c.unit, floors: {} };
- if (!grouped[c.building].floors[c.floor]) grouped[c.building].floors[c.floor] = {};
- const key = c.code + '|' + c.labName;
- if (!grouped[c.building].floors[c.floor][key])
- grouped[c.building].floors[c.floor][key] = { labName: c.labName, code: c.code, cams: [] };
- grouped[c.building].floors[c.floor][key].cams.push(c);
- });
- const tree = document.getElementById('cam-tree');
- tree.innerHTML = '';
- Object.entries(grouped).forEach(([bld, bldData], bi) => {
- const bldDiv = document.createElement('div');
- bldDiv.className = 'tr-bld';
- bldDiv.dataset.unit = bldData.unit;
- const bldLbl = document.createElement('div');
- bldLbl.className = 'tr-bld-lbl';
- const bldFloors = document.createElement('div');
- bldFloors.className = 'tr-floors' + (bi < 2 ? ' open' : '');
- bldLbl.innerHTML = `
- <span class="tr-arrow ${bi < 2 ? 'open' : ''}">▶</span>
- <span>🏢</span>
- <span style="flex:1">${bld}</span>
- <span style="font-size:10px;color:var(--text-4)">${bldData.unit}</span>
- `;
- bldLbl.addEventListener('click', () => {
- const open = bldFloors.classList.toggle('open');
- bldLbl.querySelector('.tr-arrow').classList.toggle('open', open);
- });
- Object.entries(bldData.floors).forEach(([floor, floorData], fi) => {
- const floorDiv = document.createElement('div');
- const floorLbl = document.createElement('div');
- floorLbl.className = 'tr-floor-lbl';
- const camList = document.createElement('div');
- camList.className = 'tr-cams' + (fi === 0 ? ' open' : '');
- floorLbl.innerHTML = `<span class="tr-arrow ${fi===0?'open':''}">▶</span><span>📐</span><span>${floor}</span>`;
- floorLbl.addEventListener('click', () => {
- const open = camList.classList.toggle('open');
- floorLbl.querySelector('.tr-arrow').classList.toggle('open', open);
- });
- Object.values(floorData).forEach(lab => {
- lab.cams.forEach(cam => camList.appendChild(makeCamItem(cam)));
- });
- floorDiv.appendChild(floorLbl);
- floorDiv.appendChild(camList);
- bldFloors.appendChild(floorDiv);
- });
- bldDiv.appendChild(bldLbl);
- bldDiv.appendChild(bldFloors);
- tree.appendChild(bldDiv);
- });
- }
- function makeCamItem(cam) {
- const el = document.createElement('div');
- el.className = 'cam-item';
- el.dataset.camId = cam.id;
- el.dataset.unit = cam.unit;
- el.draggable = true;
- const used = isUsed(cam.id);
- const badge = used
- ? '<span class="cam-badge used">已配置</span>'
- : cam.type === 'ai'
- ? '<span class="cam-badge ai">AI</span>'
- : '';
- el.innerHTML = `
- <span class="cam-drag">⠿</span>
- <div class="cam-dot ${cam.status}"></div>
- <span class="cam-name" title="${cam.name}">${cam.name}</span>
- ${badge}
- `;
- if (used) el.style.opacity = '';
- el.addEventListener('click', () => onCamClick(cam.id, el));
- el.addEventListener('dragstart', e => {
- e.dataTransfer.setData('camId', cam.id);
- e.dataTransfer.setData('dragType', 'fromTree');
- el.classList.add('dragging');
- });
- el.addEventListener('dragend', () => el.classList.remove('dragging'));
- return el;
- }
- function isUsed(camId) { return slots.some(s => s.camId === camId); }
- function onCamClick(camId, el) {
- const cam = CAM_DB.find(c => c.id === camId);
- if (!cam) return;
- document.querySelectorAll('.cam-item.selected-cam').forEach(e => {
- e.classList.remove('selected-cam');
- e.style.opacity = isUsed(e.dataset.camId) ? '0.55' : '';
- });
- if (selectedCamId === camId) { selectedCamId = null; return; }
- selectedCamId = camId;
- el.classList.add('selected-cam');
- el.style.opacity = '';
- if (selectedSlotPos !== null) {
- tryAssign(selectedSlotPos, camId);
- clearCamSelection();
- } else {
- toast(`已选中 ${cam.name},请点击目标槽位`);
- }
- }
- function clearCamSelection() {
- document.querySelectorAll('.cam-item.selected-cam').forEach(e => {
- e.classList.remove('selected-cam');
- e.style.opacity = isUsed(e.dataset.camId) ? '0.55' : '';
- });
- selectedCamId = null;
- }
- /* ===================================================
- SLOT GRID
- =================================================== */
- function buildGrid() {
- const grid = document.getElementById('slot-grid');
- const cols = gridMode === 9 ? 3 : 2;
- grid.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
- grid.style.gridTemplateRows = `repeat(${cols}, 1fr)`;
- grid.innerHTML = '';
- slots.forEach(slot => grid.appendChild(makeSlotEl(slot)));
- }
- function makeSlotEl(slot) {
- const cam = slot.camId ? CAM_DB.find(c => c.id === slot.camId) : null;
- const el = document.createElement('div');
- el.dataset.pos = slot.pos;
- let cls = 'slot';
- if (!cam) cls += ' empty';
- if (slot.aiRequired && cam) {
- cls += cam.type === 'ai' ? ' slot1-required' : ' slot1-warn';
- } else if (slot.aiRequired) {
- cls += ' slot1-warn';
- }
- el.className = cls;
- if (selectedSlotPos === slot.pos) el.classList.add('selected');
- renderSlotContent(el, slot, cam);
- attachSlotHandlers(el, slot.pos);
- return el;
- }
- function renderSlotContent(el, slot, cam) {
- const numBadge = `<div class="slot-num">${slot.pos}</div>`;
- if (cam) {
- const aiTag = cam.type === 'ai'
- ? `<div class="slot-ai-tag">🤖 AI</div>`
- : slot.aiRequired ? `<div class="slot-ai-tag req-warn">⚠ 须AI</div>` : '';
- const recTag = cam.status === 'online'
- ? `<div class="slot-rec">REC</div>`
- : `<div class="slot-offline-tag">📡 离线</div>`;
- const aiBox = (slot.pos === 1 && cam.type === 'ai')
- ? `<div class="ai-det-box" style="left:27%;top:16%;width:22%;height:40%"><div class="ai-det-lbl">危险行为检测</div></div>`
- : '';
- const hoverActions = `
- <div class="slot-hover-actions">
- <button class="slot-act-btn" onclick="event.stopPropagation();selectSlot(${slot.pos});toast('请从左侧选择新摄像头替换')">替换</button>
- <button class="slot-act-btn del" onclick="event.stopPropagation();doClearSlot(${slot.pos})">清除</button>
- </div>`;
- el.innerHTML = `
- ${numBadge}${aiTag}
- <canvas></canvas>
- <div class="slot-voverlay"></div>
- ${recTag}
- <div class="slot-cam-name">${cam.name}</div>
- <div class="slot-cam-loc">${cam.code} · ${cam.floor}</div>
- ${aiBox}${hoverActions}
- `;
- const canvas = el.querySelector('canvas');
- if (canvas && cam.status === 'online') setTimeout(() => drawFakeCam(canvas, slot.pos - 1), 80);
- } else {
- el.innerHTML = `
- ${numBadge}
- ${slot.aiRequired ? '<div class="slot-ai-tag req-warn">⚠ 须AI</div>' : ''}
- <div class="slot-empty">
- <div class="slot-empty-icon">${slot.aiRequired ? '🤖' : '📷'}</div>
- <div class="slot-empty-text">${slot.aiRequired ? '请分配 AI 摄像头' : '点击选择摄像头'}</div>
- <div class="slot-empty-hint">或从左侧拖拽至此</div>
- </div>
- `;
- }
- }
- function attachSlotHandlers(el, pos) {
- el.addEventListener('click', () => {
- if (selectedCamId) { tryAssign(pos, selectedCamId); clearCamSelection(); }
- else selectSlot(pos);
- });
- el.addEventListener('dragstart', e => {
- const slot = slots[pos - 1];
- if (!slot.camId) { e.preventDefault(); return; }
- e.dataTransfer.setData('dragType', 'fromSlot');
- e.dataTransfer.setData('fromPos', String(pos));
- e.dataTransfer.setData('camId', slot.camId);
- });
- el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); });
- el.addEventListener('dragleave', () => el.classList.remove('drag-over'));
- el.addEventListener('drop', e => {
- e.preventDefault(); e.stopPropagation();
- el.classList.remove('drag-over');
- const dragType = e.dataTransfer.getData('dragType');
- const camId = e.dataTransfer.getData('camId');
- if (dragType === 'fromSlot') {
- const fromPos = parseInt(e.dataTransfer.getData('fromPos'));
- if (fromPos !== pos) doSwapSlots(fromPos, pos);
- } else if (dragType === 'fromTree' && camId) {
- tryAssign(pos, camId);
- }
- });
- }
- function selectSlot(pos) {
- selectedSlotPos = pos;
- document.querySelectorAll('.slot').forEach(el => el.classList.remove('selected'));
- const el = document.querySelector(`.slot[data-pos="${pos}"]`);
- if (el) el.classList.add('selected');
- if (selectedCamId) toast('请点击槽位完成分配');
- }
- function tryAssign(pos, camId) {
- const slot = slots[pos - 1];
- const cam = CAM_DB.find(c => c.id === camId);
- if (!cam) return;
- if (slot.aiRequired && cam.type !== 'ai') { toast('⚠ 槽位1须分配 AI 智能摄像头', 'warn'); return; }
- const prev = slots.find(s => s.camId === camId && s.pos !== pos);
- if (prev) { prev.camId = null; refreshSlot(prev.pos); }
- slot.camId = camId;
- refreshSlot(pos);
- selectSlot(pos);
- rebuildTree();
- updateStats();
- toast(`✓ ${cam.name} → 槽位 ${pos}`, 'success');
- }
- function doClearSlot(pos) {
- slots[pos - 1].camId = null;
- refreshSlot(pos); rebuildTree(); updateStats();
- toast('槽位已清空');
- }
- function doSwapSlots(p1, p2) {
- const tmp = slots[p1-1].camId;
- slots[p1-1].camId = slots[p2-1].camId;
- slots[p2-1].camId = tmp;
- const s1 = slots[0];
- if (s1.aiRequired && s1.camId) {
- const c = CAM_DB.find(c => c.id === s1.camId);
- if (c && c.type !== 'ai') {
- slots[p2-1].camId = slots[p1-1].camId;
- slots[p1-1].camId = tmp;
- toast('⚠ 槽位1须保留AI摄像头,无法与普通摄像头互换', 'warn');
- return;
- }
- }
- refreshSlot(p1); refreshSlot(p2); updateStats();
- toast('槽位已互换');
- }
- function refreshSlot(pos) {
- const slot = slots[pos - 1];
- const oldEl = document.querySelector(`.slot[data-pos="${pos}"]`);
- if (!oldEl) return;
- const newEl = makeSlotEl(slot);
- if (selectedSlotPos === pos) newEl.classList.add('selected');
- oldEl.replaceWith(newEl);
- }
- function rebuildTree() {
- buildTree();
- if (selectedCamId) {
- const el = document.querySelector(`.cam-item[data-cam-id="${selectedCamId}"]`);
- if (el) el.classList.add('selected-cam');
- }
- }
- /* ===================================================
- STATS
- =================================================== */
- function updateStats() {
- const total = slots.length;
- const configured = slots.filter(s => s.camId).length;
- const empty = total - configured;
- const s1Cam = slots[0].camId ? CAM_DB.find(c => c.id === slots[0].camId) : null;
- const aiOk = s1Cam?.type === 'ai';
- const assigned = slots.filter(s => s.camId).map(s => CAM_DB.find(c => c.id === s.camId)).filter(Boolean);
- const onlinePct = configured > 0 ? Math.round(assigned.filter(c => c.status === 'online').length / configured * 100) : 0;
- document.getElementById('st-configured').textContent = `${configured} / ${total}`;
- document.getElementById('st-configured').className = `sb-val ${configured === total ? 'ok' : 'warn'}`;
- document.getElementById('st-empty').textContent = empty;
- document.getElementById('st-empty').className = `sb-val ${empty === 0 ? 'ok' : 'warn'}`;
- document.getElementById('st-ai').textContent = s1Cam ? (aiOk ? '✓ 已满足' : '✗ 非AI') : '未配置';
- document.getElementById('st-ai').className = `sb-val ${aiOk ? 'ok' : 'err'}`;
- document.getElementById('st-online-rate').textContent = configured > 0 ? `${onlinePct}%` : '--';
- const badge = document.getElementById('ai-badge');
- badge.className = `sb-constraint ${aiOk ? 'ok' : 'err'}`;
- badge.textContent = aiOk ? '✓ 槽位约束已满足' : '⚠ 槽位1需AI摄像头';
- document.getElementById('bb-validity').textContent = aiOk
- ? (configured === total ? '✓ 配置完整有效' : `⚠ 待配置 ${empty} 个槽位`)
- : '✗ 槽位1 AI约束未满足';
- }
- function updateCamCounts() {
- const online = CAM_DB.filter(c => c.status === 'online').length;
- const offline = CAM_DB.length - online;
- document.getElementById('cnt-online').textContent = online;
- document.getElementById('cnt-offline').textContent = offline;
- document.getElementById('cnt-total').textContent = CAM_DB.length;
- document.getElementById('cnt-total-badge').textContent = CAM_DB.length + ' 台';
- }
- /* ===================================================
- QUICK OPS
- =================================================== */
- function autoFill() {
- const total = slots.length;
- const aiCams = CAM_DB.filter(c => c.type === 'ai' && c.status === 'online');
- const onlineCams = CAM_DB.filter(c => c.status === 'online');
- if (!aiCams.length) { toast('没有在线 AI 摄像头', 'warn'); return; }
- slots[0].camId = aiCams[0].id;
- let used = new Set([aiCams[0].id]);
- let oi = 0;
- for (let i = 1; i < total; i++) {
- while (oi < onlineCams.length && used.has(onlineCams[oi].id)) oi++;
- slots[i].camId = oi < onlineCams.length ? (used.add(onlineCams[oi].id), onlineCams[oi++].id) : null;
- }
- buildGrid(); rebuildTree(); updateStats();
- toast('✨ 智能自动填充完成', 'success');
- }
- function clearAll() {
- slots.forEach(s => s.camId = null);
- buildGrid(); rebuildTree(); updateStats();
- toast('已清空全部槽位');
- }
- function loadLastSave() {
- const snap = gridMode === 9 ? savedSnapshot9 : savedSnapshot4;
- snap.forEach((id, i) => { if (slots[i]) slots[i].camId = id || null; });
- buildGrid(); rebuildTree(); updateStats();
- toast('↩ 已恢复至上次保存的配置', 'success');
- }
- function resetConfig() { loadLastSave(); }
- /* ===================================================
- GRID MODE SWITCH
- =================================================== */
- function switchGridMode(mode) {
- if (gridMode === mode) return;
- gridMode = mode;
- slots = mode === 9 ? slots9 : slots4;
- // Update tab active state
- document.getElementById('tab-btn-9').classList.toggle('active', mode === 9);
- document.getElementById('tab-btn-4').classList.toggle('active', mode === 4);
- // Update card title
- const titleEl = document.querySelector('.card-title-text');
- if (titleEl) titleEl.textContent = mode === 9 ? '安全大厦监控配置' : '基地监控配置';
- selectedSlotPos = null;
- selectedCamId = null;
- buildGrid(); rebuildTree(); updateStats();
- toast(`已切换至 ${mode === 9 ? '安全大厦(9格)' : '基地(4格)'} 配置`);
- }
- /* ===================================================
- FILTER
- =================================================== */
- function filterCams(kw) {
- const q = kw.toLowerCase().trim();
- document.querySelectorAll('.cam-item').forEach(el => {
- el.style.display = (!q || el.textContent.toLowerCase().includes(q)) ? '' : 'none';
- });
- }
- function filterUnit(unit) {
- document.querySelectorAll('.tr-bld').forEach(el => {
- el.style.display = (!unit || el.dataset.unit === unit) ? '' : 'none';
- });
- }
- /* ===================================================
- SAVE
- =================================================== */
- let historyLog = [];
- function doSave() {
- const apply = document.getElementById('apply-toggle').checked;
- const s1Cam = slots[0].camId ? CAM_DB.find(c => c.id === slots[0].camId) : null;
- if (slots[0].camId && s1Cam?.type !== 'ai') {
- toast('⚠ 槽位1须为 AI 摄像头,请检查配置', 'warn'); return;
- }
- if (gridMode === 9) savedSnapshot9 = slots.map(s => s.camId);
- else savedSnapshot4 = slots.map(s => s.camId);
- const now = new Date();
- historyLog.unshift({ time: now.toLocaleTimeString('zh-CN'), op: apply ? '保存并生效' : '保存草稿' });
- document.getElementById('bb-save-text').textContent = `上次保存: ${now.toLocaleTimeString('zh-CN')}`;
- toast(apply ? '✓ 已保存并生效,大屏即时更新' : '✓ 草稿已保存', 'success');
- }
- /* ===================================================
- PREVIEW
- =================================================== */
- function openPreview() {
- const modal = document.getElementById('preview-modal');
- const grid = document.getElementById('prev-grid');
- grid.innerHTML = '';
- slots.forEach((slot, i) => {
- const cam = slot.camId ? CAM_DB.find(c => c.id === slot.camId) : null;
- const cell = document.createElement('div');
- cell.className = 'prev-cell';
- if (cam && cam.status === 'online') {
- cell.innerHTML = `
- <canvas></canvas>
- <div class="prev-cell-ov"></div>
- <div class="prev-cell-num">${slot.pos}</div>
- <div class="prev-cell-name">${cam.name}</div>
- ${cam.type === 'ai' ? '<div class="prev-ai-tag">🤖 AI</div>' : ''}
- `;
- setTimeout(() => { const cv = cell.querySelector('canvas'); if (cv) drawFakeCam(cv, i); }, 80);
- } else {
- cell.innerHTML = `
- <div class="prev-cell-num">${slot.pos}</div>
- <div style="color:rgba(255,255,255,0.2);font-size:12px">${cam ? '📡 离线' : '空槽位'}</div>
- `;
- }
- grid.appendChild(cell);
- });
- modal.classList.add('show');
- }
- function closePreview() {
- document.getElementById('preview-modal').classList.remove('show');
- }
- /* ===================================================
- FAKE CAMERA CANVAS
- =================================================== */
- function drawFakeCam(canvas, idx) {
- const ctx = canvas.getContext('2d');
- const par = canvas.parentElement;
- let pw, ph;
- function sz() {
- pw = canvas.width = par.offsetWidth || canvas.clientWidth || 320;
- ph = canvas.height = par.offsetHeight || canvas.clientHeight || 180;
- }
- sz();
- let t = Math.random() * 1000;
- const noise = Array.from({length:60}, () => ({ x:Math.random(), y:Math.random(), s:Math.random()*2, a:Math.random()*0.2 }));
- function frame() {
- if (!canvas.isConnected) return;
- t += 0.5;
- if (!pw || !ph) sz();
- ctx.clearRect(0, 0, pw, ph);
- const bg = ctx.createLinearGradient(0, 0, 0, ph);
- bg.addColorStop(0, 'hsl(210,42%,5%)');
- bg.addColorStop(1, 'hsl(220,30%,3%)');
- ctx.fillStyle = bg; ctx.fillRect(0, 0, pw, ph);
- ctx.strokeStyle = 'rgba(20,70,150,0.06)'; ctx.lineWidth = 0.5;
- const vp = { x: pw/2, y: ph*0.45 };
- for (let gx = 0; gx <= 8; gx++) {
- const fx = pw*gx/8;
- ctx.beginPath(); ctx.moveTo(fx, ph); ctx.lineTo(vp.x+(fx-pw/2)*0.3, vp.y); ctx.stroke();
- }
- for (let gy = 0; gy <= 4; gy++) {
- const fy = ph*0.45+ph*0.55*(gy/4);
- const wd = pw*(0.3+0.7*(gy/4));
- ctx.beginPath(); ctx.moveTo((pw-wd)/2, fy); ctx.lineTo((pw+wd)/2, fy); ctx.stroke();
- }
- ctx.fillStyle = 'rgba(0,35,90,0.35)';
- ctx.fillRect(pw*0.08, ph*0.56, pw*0.38, ph*0.07);
- ctx.fillRect(pw*0.54, ph*0.56, pw*0.38, ph*0.07);
- ctx.fillStyle = 'rgba(0,18,55,0.45)';
- ctx.fillRect(pw*0.12, ph*0.43, pw*0.07, ph*0.13);
- ctx.fillRect(pw*0.58, ph*0.41, pw*0.09, ph*0.15);
- const np = [2,1,1,0,2,1,0,1,2][idx%9] || 1;
- for (let p = 0; p < np; p++) {
- const px = pw*(0.28+p*0.2+Math.sin(t*0.007+p)*0.012);
- const py = ph*(0.62+Math.cos(t*0.005+p)*0.012);
- ctx.fillStyle = 'rgba(120,170,255,0.55)';
- ctx.beginPath(); ctx.arc(px, py, pw*0.018, 0, Math.PI*2); ctx.fill();
- ctx.fillStyle = 'rgba(70,130,220,0.32)';
- ctx.fillRect(px-pw*0.012, py+pw*0.018, pw*0.025, ph*0.055);
- }
- const sl = (t*0.35)%ph;
- ctx.fillStyle = 'rgba(30,144,255,0.035)'; ctx.fillRect(0, sl, pw, 1.5);
- noise.forEach(n => {
- ctx.fillStyle = `rgba(150,195,255,${n.a*0.35})`;
- ctx.fillRect(n.x*pw, n.y*ph, n.s, n.s);
- if (Math.random()<0.015) { n.x=Math.random(); n.y=Math.random(); }
- });
- requestAnimationFrame(frame);
- }
- frame();
- }
- /* ===================================================
- TOAST
- =================================================== */
- function toast(msg, type = 'info') {
- const el = document.getElementById('toast');
- el.textContent = msg;
- el.className = 'toast show ' + (type === 'success' ? 'success' : type === 'warn' ? 'warn' : type === 'err' ? 'err' : '');
- clearTimeout(el._t);
- el._t = setTimeout(() => el.classList.remove('show'), 2800);
- }
- </script>
- </body>
- </html>
|