2026-08-04 00:23:55 +08:00
<!doctype html>
< html lang = "zh-CN" data-theme = "dark" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< meta name = "color-scheme" content = "dark light" >
< link rel = "icon" href = "data:," >
< title > YoVision Sense · 设备与视频接入原型</ title >
< style >
: root {
--bg : #08111f ;
--surface : #0d1929 ;
--surface-2 : #111f31 ;
--surface-3 : #17263a ;
--text : #f2f7fb ;
--muted : #9fb0c5 ;
--subtle : #71849c ;
--line : #27384d ;
--primary : #38bdf8 ;
--primary-strong : #0ea5e9 ;
--primary-soft : rgba ( 56 , 189 , 248 , .14 );
--success : #34d399 ;
--warning : #fbbf24 ;
--danger : #fb7185 ;
--violet : #a78bfa ;
--shadow : 0 16 px 40 px rgba ( 0 , 0 , 0 , .22 );
--radius : 14 px ;
--sidebar : 224 px ;
--banner : 28 px ;
}
html [ data-theme = "light" ] {
--bg : #eef4f8 ;
--surface : #ffffff ;
--surface-2 : #f7fafc ;
--surface-3 : #eaf1f6 ;
--text : #112033 ;
--muted : #50647b ;
--subtle : #718096 ;
--line : #cfdae5 ;
--primary-soft : rgba ( 2 , 132 , 199 , .11 );
--shadow : 0 14 px 34 px rgba ( 38 , 64 , 92 , .12 );
}
* { box-sizing : border-box ; }
html { min-width : 320 px ; background : var ( -- bg ); }
body {
margin : 0 ;
min-height : 100 dvh ;
background : var ( -- bg );
color : var ( -- text );
font : 14 px / 1.5 Inter , "Segoe UI" , "PingFang SC" , "Microsoft YaHei" , sans-serif ;
}
button , input , select , textarea { font : inherit ; }
button , select , input [ type = "checkbox" ], input [ type = "radio" ] { cursor : pointer ; }
button : focus-visible , input : focus-visible , select : focus-visible , textarea : focus-visible , [ tabindex ] : focus-visible {
outline : 3 px solid rgba ( 56 , 189 , 248 , .45 );
outline-offset : 2 px ;
}
. prototype-banner {
position : fixed ; inset : 0 0 auto 0 ; z-index : 1000 ;
height : var ( -- banner ); display : flex ; align-items : center ; justify-content : center ;
background : #f6c344 ; color : #211900 ; font-size : 12 px ; font-weight : 800 ; letter-spacing : .07 em ;
}
. icon { width : 18 px ; height : 18 px ; fill : none ; stroke : currentColor ; stroke-width : 1.8 ; stroke-linecap : round ; stroke-linejoin : round ; flex : 0 0 auto ; }
. app { min-height : 100 dvh ; padding-top : var ( -- banner ); display : grid ; grid-template-columns : var ( -- sidebar ) minmax ( 0 , 1 fr ); }
. sidebar {
position : sticky ; top : var ( -- banner ); height : calc ( 100 dvh - var ( -- banner ));
border-right : 1 px solid var ( -- line ); background : var ( -- surface ); padding : 18 px 12 px ;
display : flex ; flex-direction : column ; z-index : 30 ;
}
. brand { display : flex ; align-items : center ; gap : 10 px ; padding : 2 px 10 px 20 px ; }
. brand-mark { width : 34 px ; height : 34 px ; display : grid ; place-items : center ; border-radius : 10 px ; background : linear-gradient ( 145 deg , #38bdf8 , #6366f1 ); color : white ; font-weight : 900 ; box-shadow : 0 8 px 20 px rgba ( 14 , 165 , 233 , .22 ); }
. brand strong { display : block ; font-size : 16 px ; letter-spacing : .01 em ; }
. brand small { color : var ( -- muted ); }
. nav-label { margin : 8 px 10 px 6 px ; color : var ( -- subtle ); font-size : 11 px ; font-weight : 700 ; text-transform : uppercase ; letter-spacing : .12 em ; }
. nav { display : grid ; gap : 4 px ; }
. nav button {
min-height : 44 px ; padding : 0 12 px ; border : 0 ; border-radius : 10 px ; background : transparent ; color : var ( -- muted );
display : flex ; align-items : center ; gap : 10 px ; text-align : left ; transition : background .2 s ease , color .2 s ease ;
}
. nav button : hover { color : var ( -- text ); background : var ( -- surface -2 ); }
. nav button [ aria-current = "page" ] { color : var ( -- text ); background : var ( -- primary - soft ); box-shadow : inset 3 px 0 var ( -- primary ); }
. nav . count { margin-left : auto ; min-width : 22 px ; padding : 1 px 6 px ; border-radius : 999 px ; background : var ( -- surface -3 ); color : var ( -- text ); text-align : center ; font-size : 12 px ; }
. sidebar-foot { margin-top : auto ; padding : 12 px 10 px 2 px ; border-top : 1 px solid var ( -- line ); color : var ( -- muted ); }
. online-row { display : flex ; align-items : center ; gap : 8 px ; margin-bottom : 4 px ; }
. dot { width : 8 px ; height : 8 px ; border-radius : 50 % ; background : var ( -- success ); box-shadow : 0 0 0 4 px rgba ( 52 , 211 , 153 , .12 ); }
. main { min-width : 0 ; }
. topbar {
position : sticky ; top : var ( -- banner ); z-index : 20 ; min-height : 64 px ; padding : 10 px 22 px ;
display : flex ; align-items : center ; gap : 12 px ; border-bottom : 1 px solid var ( -- line ); background : color-mix ( in srgb , var ( -- surface ) 94 % , transparent ); backdrop-filter : blur ( 12 px );
}
. site-select { display : flex ; align-items : center ; gap : 8 px ; min-width : 250 px ; }
. site-select strong { font-size : 15 px ; }
. site-select span { color : var ( -- muted ); font-size : 12 px ; }
. top-actions { margin-left : auto ; display : flex ; align-items : center ; gap : 8 px ; }
. select , . field , . textarea {
width : 100 % ; min-height : 40 px ; border : 1 px solid var ( -- line ); border-radius : 9 px ; background : var ( -- surface -2 ); color : var ( -- text ); padding : 8 px 11 px ;
}
. textarea { min-height : 74 px ; resize : vertical ; }
. top-actions . select { width : auto ; min-width : 126 px ; }
. btn {
min-height : 40 px ; border : 1 px solid var ( -- line ); border-radius : 9 px ; padding : 8 px 13 px ; background : var ( -- surface -2 ); color : var ( -- text );
2026-08-04 09:04:50 +08:00
display : inline-flex ; align-items : center ; justify-content : center ; gap : 8 px ; font-weight : 700 ; text-decoration : none ; transition : border-color .2 s ease , background .2 s ease , opacity .2 s ease ;
2026-08-04 00:23:55 +08:00
}
. btn : hover { border-color : var ( -- primary ); }
. btn-primary { background : var ( -- primary - strong ); border-color : var ( -- primary - strong ); color : #fff ; }
. btn-danger { color : #ffd8df ; border-color : rgba ( 251 , 113 , 133 , .45 ); background : rgba ( 251 , 113 , 133 , .1 ); }
. btn-quiet { border-color : transparent ; background : transparent ; color : var ( -- muted ); }
. icon-btn { width : 42 px ; padding : 0 ; }
. content { padding : 22 px ; }
. view { display : none ; animation : reveal .22 s ease-out ; }
. view . active { display : block ; }
@ keyframes reveal { from { opacity : 0 ; transform : translateY ( 4 px ); } to { opacity : 1 ; transform : none ; } }
. page-head { display : flex ; align-items : flex-start ; justify-content : space-between ; gap : 16 px ; margin-bottom : 18 px ; }
. page-head h1 { margin : 0 0 4 px ; font-size : 24 px ; line-height : 1.25 ; }
. page-head p { margin : 0 ; color : var ( -- muted ); }
. page-actions { display : flex ; flex-wrap : wrap ; gap : 8 px ; }
. grid { display : grid ; gap : 14 px ; }
. stats { grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr )); }
. card { border : 1 px solid var ( -- line ); border-radius : var ( -- radius ); background : var ( -- surface ); box-shadow : var ( -- shadow ); }
. stat { padding : 16 px ; min-height : 116 px ; display : flex ; flex-direction : column ; justify-content : space-between ; }
. stat-top { display : flex ; align-items : center ; justify-content : space-between ; color : var ( -- muted ); }
. stat strong { font-size : 27 px ; font-variant-numeric : tabular-nums ; }
. trend { font-size : 12 px ; color : var ( -- success ); }
. split { grid-template-columns : minmax ( 0 , 1.6 fr ) minmax ( 300 px , .8 fr ); margin-top : 14 px ; }
. card-head { padding : 14 px 16 px ; display : flex ; align-items : center ; justify-content : space-between ; gap : 10 px ; border-bottom : 1 px solid var ( -- line ); }
. card-head h2 , . card-head h3 { margin : 0 ; font-size : 15 px ; }
. card-body { padding : 16 px ; }
. health-list { display : grid ; gap : 12 px ; }
. health-row { display : grid ; grid-template-columns : 1 fr auto ; gap : 4 px 12 px ; }
. health-row span { color : var ( -- muted ); }
. health-row strong { font-variant-numeric : tabular-nums ; }
. meter { grid-column : 1 /- 1 ; height : 7 px ; border-radius : 99 px ; background : var ( -- surface -3 ); overflow : hidden ; }
. meter i { display : block ; height : 100 % ; border-radius : inherit ; background : var ( -- primary ); }
. badge { display : inline-flex ; align-items : center ; gap : 5 px ; min-height : 24 px ; padding : 2 px 8 px ; border-radius : 999 px ; border : 1 px solid var ( -- line ); color : var ( -- muted ); font-size : 12 px ; white-space : nowrap ; }
. badge . success { color : #74efc1 ; border-color : rgba ( 52 , 211 , 153 , .35 ); background : rgba ( 52 , 211 , 153 , .1 ); }
. badge . warning { color : #ffdc74 ; border-color : rgba ( 251 , 191 , 36 , .35 ); background : rgba ( 251 , 191 , 36 , .1 ); }
. badge . danger { color : #ff9aae ; border-color : rgba ( 251 , 113 , 133 , .4 ); background : rgba ( 251 , 113 , 133 , .1 ); }
. badge . info { color : #8edaff ; border-color : rgba ( 56 , 189 , 248 , .35 ); background : var ( -- primary - soft ); }
. toolbar { display : flex ; flex-wrap : wrap ; align-items : center ; gap : 8 px ; }
. search { position : relative ; min-width : 220 px ; }
. search . icon { position : absolute ; left : 11 px ; top : 11 px ; color : var ( -- subtle ); pointer-events : none ; }
. search input { padding-left : 38 px ; }
. table-wrap { overflow-x : auto ; }
2026-08-04 09:04:50 +08:00
table { width : 100 % ; border-collapse : collapse ; min-width : 960 px ; }
2026-08-04 00:23:55 +08:00
th , td { padding : 12 px 14 px ; border-bottom : 1 px solid var ( -- line ); text-align : left ; vertical-align : middle ; }
th { color : var ( -- muted ); background : var ( -- surface -2 ); font-size : 12 px ; font-weight : 700 ; }
td { font-variant-numeric : tabular-nums ; }
tr : last-child td { border-bottom : 0 ; }
tr : hover td { background : color-mix ( in srgb , var ( -- primary - soft ) 45 % , transparent ); }
. device-name { display : flex ; align-items : center ; gap : 10 px ; }
. camera-icon { width : 34 px ; height : 34 px ; border-radius : 9 px ; display : grid ; place-items : center ; background : var ( -- surface -3 ); color : var ( -- primary ); }
. device-name small { display : block ; color : var ( -- muted ); }
. camera-grid { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); }
. camera-card { overflow : hidden ; }
. camera-stage { aspect-ratio : 16 / 9 ; position : relative ; background : #020914 ; overflow : hidden ; }
. camera-stage :: before { content : "" ; position : absolute ; inset : 0 ; background : linear-gradient ( 160 deg , transparent 45 % , rgba ( 56 , 189 , 248 , .08 )), repeating-linear-gradient ( 0 deg , rgba ( 255 , 255 , 255 , .025 ) 0 1 px , transparent 1 px 4 px ); }
. scene { position : absolute ; inset : 0 ; width : 100 % ; height : 100 % ; }
. camera-meta { padding : 12 px 14 px ; display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; }
. camera-meta small { color : var ( -- muted ); }
2026-08-04 09:04:50 +08:00
. breadcrumb { margin-bottom : 10 px ; display : flex ; align-items : center ; gap : 7 px ; color : var ( -- muted ); font-size : 12 px ; }
. breadcrumb button { min-height : 32 px ; padding : 4 px 8 px ; border : 0 ; border-radius : 7 px ; background : transparent ; color : var ( -- primary ); font-weight : 700 ; }
. breadcrumb button : hover { background : var ( -- primary - soft ); }
. detail-tabs { margin-bottom : 12 px ; padding : 5 px ; display : flex ; gap : 5 px ; overflow-x : auto ; border : 1 px solid var ( -- line ); border-radius : 11 px ; background : var ( -- surface ); }
. detail-tabs button { min-height : 40 px ; padding : 7 px 13 px ; border : 0 ; border-radius : 8 px ; background : transparent ; color : var ( -- muted ); white-space : nowrap ; }
. detail-tabs button . active { background : var ( -- primary - soft ); color : var ( -- text ); box-shadow : inset 0 -2 px var ( -- primary ); }
. detail-panel [ hidden ] { display : none ; }
. context-strip { margin-bottom : 12 px ; padding : 11 px 13 px ; display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; border : 1 px solid rgba ( 167 , 139 , 250 , .38 ); border-radius : 10 px ; background : rgba ( 167 , 139 , 250 , .1 ); }
. context-strip [ hidden ] { display : none ; }
. context-strip p { margin : 0 ; color : var ( -- muted ); }
. zone-layout { display : grid ; grid-template-columns : minmax ( 420 px , 1 fr ) 320 px ; gap : 12 px ; min-height : calc ( 100 dvh - 260 px ); }
2026-08-04 00:23:55 +08:00
. zone-camera-list , . zone-settings , . zone-workspace { min-width : 0 ; }
. compact-list { display : grid ; gap : 6 px ; padding : 10 px ; }
. compact-list button { width : 100 % ; min-height : 54 px ; border : 1 px solid transparent ; border-radius : 9 px ; background : transparent ; color : var ( -- text ); display : flex ; align-items : center ; gap : 10 px ; padding : 8 px ; text-align : left ; }
. compact-list button : hover , . compact-list button . selected { background : var ( -- primary - soft ); border-color : rgba ( 56 , 189 , 248 , .32 ); }
. compact-list small { display : block ; color : var ( -- muted ); }
. mini-thumb { width : 54 px ; height : 34 px ; border-radius : 6 px ; background : linear-gradient ( 145 deg , #24364c , #0a101a ); position : relative ; overflow : hidden ; }
. mini-thumb :: after { content : "" ; position : absolute ; left : 8 px ; right : 8 px ; bottom : 8 px ; height : 1 px ; background : var ( -- primary ); transform : rotate ( -12 deg ); }
. editor-tools { padding : 10 px ; display : flex ; gap : 6 px ; align-items : center ; border-bottom : 1 px solid var ( -- line ); overflow-x : auto ; }
. tool-btn { min-width : 42 px ; min-height : 40 px ; border : 1 px solid var ( -- line ); border-radius : 8 px ; background : var ( -- surface -2 ); color : var ( -- muted ); display : inline-flex ; align-items : center ; justify-content : center ; gap : 6 px ; white-space : nowrap ; }
. tool-btn . active { background : var ( -- primary - soft ); border-color : var ( -- primary ); color : var ( -- text ); }
. tool-divider { width : 1 px ; align-self : stretch ; background : var ( -- line ); margin : 0 3 px ; }
. editor-stage { position : relative ; aspect-ratio : 16 / 9 ; min-height : 340 px ; background : #030a12 ; overflow : hidden ; }
. editor-stage svg { width : 100 % ; height : 100 % ; display : block ; touch-action : none ; }
. editor-hint { position : absolute ; left : 12 px ; bottom : 12 px ; padding : 7 px 10 px ; border-radius : 8 px ; background : rgba ( 3 , 10 , 18 , .78 ); color : #dceaff ; font-size : 12 px ; pointer-events : none ; }
. editor-status { display : flex ; justify-content : space-between ; gap : 10 px ; padding : 10 px 12 px ; color : var ( -- muted ); border-top : 1 px solid var ( -- line ); font-size : 12 px ; }
. form-grid { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 12 px ; }
. form-field { display : grid ; gap : 6 px ; }
. form-field . full { grid-column : 1 /- 1 ; }
. form-field label { color : var ( -- muted ); font-size : 12 px ; font-weight : 700 ; }
. switch-row { display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; min-height : 42 px ; }
. switch { position : relative ; width : 42 px ; height : 24 px ; }
. switch input { position : absolute ; opacity : 0 ; }
. switch span { position : absolute ; inset : 0 ; border-radius : 99 px ; background : var ( -- surface -3 ); border : 1 px solid var ( -- line ); transition : .2 s ease ; }
. switch span :: after { content : "" ; position : absolute ; width : 18 px ; height : 18 px ; left : 2 px ; top : 2 px ; border-radius : 50 % ; background : var ( -- muted ); transition : transform .2 s ease , background .2 s ease ; }
. switch input : checked + span { background : var ( -- primary - strong ); border-color : var ( -- primary - strong ); }
. switch input : checked + span :: after { transform : translateX ( 18 px ); background : white ; }
. coordinate-box { margin-top : 12 px ; padding : 12 px ; border : 1 px dashed var ( -- line ); border-radius : 10 px ; background : var ( -- surface -2 ); }
. coordinate-box h3 { margin : 0 0 4 px ; font-size : 13 px ; }
. coordinate-box p { margin : 0 0 10 px ; color : var ( -- muted ); font-size : 12 px ; }
. coordinate-row { display : grid ; grid-template-columns : 1 fr 1 fr auto ; gap : 6 px ; }
2026-08-04 09:04:50 +08:00
. linked-rules { margin-top : 12 px ; padding : 12 px ; border : 1 px solid var ( -- line ); border-radius : 10 px ; background : var ( -- surface -2 ); }
. linked-rules h3 { margin : 0 0 4 px ; font-size : 13 px ; }
. linked-rules p { margin : 0 0 10 px ; color : var ( -- muted ); font-size : 12 px ; }
. linked-rules ul { margin : 0 0 11 px ; padding-left : 18 px ; color : var ( -- muted ); }
2026-08-04 00:23:55 +08:00
. settings-actions { position : sticky ; bottom : 0 ; margin : 14 px -16 px -16 px ; padding : 12 px 16 px ; display : flex ; gap : 8 px ; justify-content : flex-end ; border-top : 1 px solid var ( -- line ); background : var ( -- surface ); }
. drop-zone { padding : 28 px ; border : 1 px dashed var ( -- line ); border-radius : 12 px ; text-align : center ; background : var ( -- surface -2 ); }
. drop-zone . icon { width : 28 px ; height : 28 px ; color : var ( -- primary ); }
. drop-zone p { margin : 8 px 0 12 px ; color : var ( -- muted ); }
. system-grid { grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr )); }
. system-tile { padding : 16 px ; }
. system-tile h3 { margin : 0 0 12 px ; font-size : 14 px ; }
. system-tile dl { margin : 0 ; display : grid ; grid-template-columns : 1 fr auto ; gap : 8 px ; color : var ( -- muted ); }
. system-tile dd { margin : 0 ; color : var ( -- text ); font-variant-numeric : tabular-nums ; }
. state-layer { display : none ; min-height : 380 px ; place-items : center ; border : 1 px dashed var ( -- line ); border-radius : var ( -- radius ); background : var ( -- surface ); text-align : center ; padding : 24 px ; }
. state-layer . show { display : grid ; }
. state-layer . icon { width : 38 px ; height : 38 px ; color : var ( -- primary ); }
. state-layer h2 { margin : 12 px 0 4 px ; font-size : 18 px ; }
. state-layer p { margin : 0 0 14 px ; color : var ( -- muted ); max-width : 440 px ; }
. skeleton { width : min ( 420 px , 90 % ); display : grid ; gap : 10 px ; }
. skeleton i { display : block ; height : 14 px ; border-radius : 6 px ; background : linear-gradient ( 90 deg , var ( -- surface -3 ), var ( -- line ), var ( -- surface -3 )); background-size : 200 % 100 % ; animation : shimmer 1.4 s infinite ; }
. skeleton i : nth-child ( 2 ) { width : 70 % ; }
@ keyframes shimmer { to { background-position : -200 % 0 ; } }
dialog { width : min ( 680 px , calc ( 100 % - 28 px )); max-height : 88 dvh ; border : 1 px solid var ( -- line ); border-radius : 16 px ; padding : 0 ; background : var ( -- surface ); color : var ( -- text ); box-shadow : 0 24 px 80 px rgba ( 0 , 0 , 0 , .5 ); }
dialog :: backdrop { background : rgba ( 0 , 8 , 18 , .64 ); backdrop-filter : blur ( 4 px ); }
. dialog-head , . dialog-foot { padding : 14 px 18 px ; display : flex ; align-items : center ; justify-content : space-between ; gap : 10 px ; border-bottom : 1 px solid var ( -- line ); }
. dialog-head h2 { margin : 0 ; font-size : 17 px ; }
. dialog-body { padding : 18 px ; overflow : auto ; }
. dialog-foot { border : 0 ; border-top : 1 px solid var ( -- line ); justify-content : flex-end ; }
. toast { position : fixed ; right : 20 px ; bottom : 20 px ; z-index : 1100 ; max-width : min ( 390 px , calc ( 100 % - 40 px )); padding : 12 px 14 px ; border : 1 px solid rgba ( 52 , 211 , 153 , .35 ); border-radius : 10 px ; background : #0b2c25 ; color : #d9fff3 ; box-shadow : var ( -- shadow ); transform : translateY ( 20 px ); opacity : 0 ; pointer-events : none ; transition : .2 s ease ; }
. toast . show { transform : none ; opacity : 1 ; }
. muted { color : var ( -- muted ); }
. mono { font-family : ui-monospace , SFMono-Regular , Consolas , monospace ; font-variant-numeric : tabular-nums ; }
. mobile-nav { display : none ; }
@ media ( max-width : 1180px ) {
: root { --sidebar : 78 px ; }
. brand-copy , . nav span : not ( . count ), . nav-label , . sidebar-foot { display : none ; }
. brand { justify-content : center ; padding-inline : 0 ; }
. nav button { justify-content : center ; padding : 0 ; }
. nav . count { position : absolute ; margin : -24 px 0 0 25 px ; }
2026-08-04 09:04:50 +08:00
. zone-layout { grid-template-columns : 1 fr ; }
. zone-settings { grid-column : auto ; }
2026-08-04 00:23:55 +08:00
. settings-actions { position : static ; margin-bottom : -16 px ; }
}
@ media ( max-width : 820px ) {
: root { --sidebar : 0 px ; }
. app { display : block ; padding-bottom : 66 px ; }
. sidebar { display : none ; }
. topbar { padding : 8 px 12 px ; }
. site-select { min-width : 0 ; }
. site-select span , . top-actions . select { display : none ; }
. content { padding : 14 px 12 px ; }
. stats , . system-grid { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); }
. split , . zone-layout { grid-template-columns : 1 fr ; }
2026-08-04 09:04:50 +08:00
. editor-stage { min-height : 0 ; }
. context-strip , . linked-zone { display : grid ; }
2026-08-04 00:23:55 +08:00
. zone-camera-list { order : 2 ; }
. zone-workspace { order : 1 ; }
. zone-settings { order : 3 ; }
. camera-grid { grid-template-columns : 1 fr ; }
. mobile-nav { position : fixed ; z-index : 50 ; left : 8 px ; right : 8 px ; bottom : 8 px ; display : grid ; grid-template-columns : repeat ( 5 , 1 fr ); padding : 5 px ; border : 1 px solid var ( -- line ); border-radius : 14 px ; background : var ( -- surface ); box-shadow : var ( -- shadow ); }
2026-08-04 09:07:18 +08:00
. mobile-nav button { min-height : 48 px ; border : 0 ; border-radius : 9 px ; background : transparent ; color : var ( -- muted ); display : grid ; place-items : center ; font-size : 11 px ; }
. mobile-nav button . active { color : var ( -- primary ); background : var ( -- primary - soft ); }
}
@ media ( max-width : 560px ) {
. stats , . system-grid { grid-template-columns : 1 fr ; }
. page-head { display : grid ; }
. page-actions { width : 100 % ; }
. page-actions . btn { flex : 1 ; }
. form-grid { grid-template-columns : 1 fr ; }
. form-field . full { grid-column : auto ; }
. editor-stage { min-height : 0 ; }
. coordinate-row { grid-template-columns : 1 fr 1 fr ; }
. coordinate-row . btn { grid-column : 1 /- 1 ; }
}
@ media ( prefers-reduced-motion : reduce ) {
*, * :: before , * :: after { animation-duration : .01 ms !important ; animation-iteration-count : 1 !important ; transition-duration : .01 ms !important ; scroll-behavior : auto !important ; }
}
</ style >
</ head >
< body >
< svg aria-hidden = "true" width = "0" height = "0" style = "position:absolute" >
< symbol id = "i-grid" viewBox = "0 0 24 24" >< rect x = "3" y = "3" width = "7" height = "7" />< rect x = "14" y = "3" width = "7" height = "7" />< rect x = "3" y = "14" width = "7" height = "7" />< rect x = "14" y = "14" width = "7" height = "7" /></ symbol >
< symbol id = "i-video" viewBox = "0 0 24 24" >< rect x = "3" y = "5" width = "14" height = "14" rx = "2" />< path d = "m17 10 4-2v8l-4-2z" /></ symbol >
< symbol id = "i-camera" viewBox = "0 0 24 24" >< path d = "M5 7h3l1.4-2h5.2L16 7h3a2 2 0 0 1 2 2v9H3V9a2 2 0 0 1 2-2Z" />< circle cx = "12" cy = "12.5" r = "3" /></ symbol >
< symbol id = "i-zone" viewBox = "0 0 24 24" >< path d = "m5 5 14 2-2 12-12-3Z" />< circle cx = "5" cy = "5" r = "1.5" />< circle cx = "19" cy = "7" r = "1.5" />< circle cx = "17" cy = "19" r = "1.5" />< circle cx = "5" cy = "16" r = "1.5" /></ symbol >
< symbol id = "i-upload" viewBox = "0 0 24 24" >< path d = "M12 16V4m0 0L7 9m5-5 5 5" />< path d = "M4 15v5h16v-5" /></ symbol >
< symbol id = "i-pulse" viewBox = "0 0 24 24" >< path d = "M3 12h4l2-6 4 12 2-6h6" /></ symbol >
< symbol id = "i-search" viewBox = "0 0 24 24" >< circle cx = "11" cy = "11" r = "7" />< path d = "m20 20-4-4" /></ symbol >
< symbol id = "i-plus" viewBox = "0 0 24 24" >< path d = "M12 5v14M5 12h14" /></ symbol >
< symbol id = "i-sun" viewBox = "0 0 24 24" >< circle cx = "12" cy = "12" r = "4" />< path d = "M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></ symbol >
< symbol id = "i-bell" viewBox = "0 0 24 24" >< path d = "M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />< path d = "M10 21h4" /></ symbol >
< symbol id = "i-chevron" viewBox = "0 0 24 24" >< path d = "m9 18 6-6-6-6" /></ symbol >
< symbol id = "i-pointer" viewBox = "0 0 24 24" >< path d = "m5 3 14 8-6 2-3 6Z" /></ symbol >
< symbol id = "i-line" viewBox = "0 0 24 24" >< path d = "m4 18 16-12" />< circle cx = "4" cy = "18" r = "2" />< circle cx = "20" cy = "6" r = "2" /></ symbol >
< symbol id = "i-undo" viewBox = "0 0 24 24" >< path d = "M9 7 4 12l5 5" />< path d = "M5 12h8a6 6 0 0 1 6 6" /></ symbol >
< symbol id = "i-trash" viewBox = "0 0 24 24" >< path d = "M4 7h16M9 7V4h6v3m-9 0 1 14h10l1-14" /></ symbol >
< symbol id = "i-alert" viewBox = "0 0 24 24" >< path d = "M12 3 2 20h20Z" />< path d = "M12 9v4m0 3v.01" /></ symbol >
< symbol id = "i-check" viewBox = "0 0 24 24" >< path d = "m5 12 4 4L19 6" /></ symbol >
< symbol id = "i-refresh" viewBox = "0 0 24 24" >< path d = "M20 6v5h-5M4 18v-5h5" />< path d = "M18 9a7 7 0 0 0-12-2M6 15a7 7 0 0 0 12 2" /></ symbol >
< symbol id = "i-more" viewBox = "0 0 24 24" >< circle cx = "5" cy = "12" r = "1" />< circle cx = "12" cy = "12" r = "1" />< circle cx = "19" cy = "12" r = "1" /></ symbol >
< symbol id = "i-wifi" viewBox = "0 0 24 24" >< path d = "M4 9a12 12 0 0 1 16 0M7 13a8 8 0 0 1 10 0m-7 4a3 3 0 0 1 4 0M12 20v.01" /></ symbol >
</ svg >
< div class = "prototype-banner" > PROTOTYPE - 仅供枚举交互,非实现依据</ div >
< div class = "app" >
< aside class = "sidebar" aria-label = "Sense 主导航" >
< div class = "brand" >< div class = "brand-mark" > Y</ div >< div class = "brand-copy" >< strong > YoVision</ strong >< small > Sense · Edge Console</ small ></ div ></ div >
< div class = "nav-label" > Workspace</ div >
< nav class = "nav" >
< button data-view = "overview" >< svg class = "icon" >< use href = "#i-grid" /></ svg >< span > 运行总览</ span ></ button >
< button data-view = "monitor" >< svg class = "icon" >< use href = "#i-video" /></ svg >< span > 实时监控</ span >< span class = "count" > 4</ span ></ button >
< button data-view = "devices" aria-current = "page" >< svg class = "icon" >< use href = "#i-camera" /></ svg >< span > 摄像头</ span >< span class = "count" > 16</ span ></ button >
< button data-view = "onboarding" >< svg class = "icon" >< use href = "#i-upload" /></ svg >< span > 接入任务</ span >< span class = "count" > 1</ span ></ button >
< button data-view = "system" >< svg class = "icon" >< use href = "#i-pulse" /></ svg >< span > 系统状态</ span ></ button >
</ nav >
< div class = "sidebar-foot" >< div class = "online-row" >< span class = "dot" ></ span >< strong > 边缘节点在线</ strong ></ div >< div class = "copy" > sense-edge-01< br >< span class = "mono" > v0.1-prototype</ span ></ div ></ div >
</ aside >
< main class = "main" id = "main-content" tabindex = "-1" >
< header class = "topbar" >
< div class = "site-select" >< div >< strong > 青藤寄宿学校</ strong >< span > / 主校区 · 16 / 16 路</ span ></ div >< svg class = "icon" >< use href = "#i-chevron" /></ svg ></ div >
< div class = "top-actions" >
< select class = "select" id = "stateSelect" aria-label = "原型状态演示" >
< option value = "normal" > 正常状态</ option >< option value = "loading" > 加载中</ option >< option value = "empty" > 空数据</ option >< option value = "error" > 连接失败</ option >
</ select >
< button class = "btn icon-btn" id = "themeToggle" aria-label = "切换明暗主题" title = "切换明暗主题" >< svg class = "icon" >< use href = "#i-sun" /></ svg ></ button >
< button class = "btn icon-btn" aria-label = "查看运维通知" title = "查看运维通知" >< svg class = "icon" >< use href = "#i-bell" /></ svg ></ button >
</ div >
</ header >
< div class = "content" >
< section class = "state-layer" id = "stateLayer" aria-live = "polite" ></ section >
< section class = "view" data-page = "overview" >
< div class = "page-head" >< div >< h1 > 运行总览</ h1 >< p > 先判断是否影响业务,再进入设备或分片定位原因。</ p ></ div >< div class = "page-actions" >< button class = "btn" >< svg class = "icon" >< use href = "#i-refresh" /></ svg > 立即对账</ button ></ div ></ div >
< div class = "grid stats" >
< article class = "card stat" >< div class = "stat-top" >< span > 在线设备</ span >< svg class = "icon" >< use href = "#i-camera" /></ svg ></ div >< strong > 15 / 16</ strong >< span class = "trend" > 93.8% · 1 路重连中</ span ></ article >
< article class = "card stat" >< div class = "stat-top" >< span > 活跃媒体流</ span >< svg class = "icon" >< use href = "#i-video" /></ svg ></ div >< strong > 15</ strong >< span class = "trend" > 主/子码流均衡</ span ></ article >
< article class = "card stat" >< div class = "stat-top" >< span > 待收敛项</ span >< svg class = "icon" >< use href = "#i-refresh" /></ svg ></ div >< strong > 2</ strong >< span class = "muted" > 配置 1 · 时间漂移 1</ span ></ article >
< article class = "card stat" >< div class = "stat-top" >< span > 今日设备告警</ span >< svg class = "icon" >< use href = "#i-alert" /></ svg ></ div >< strong > 3</ strong >< span class = "muted" > 与业务预警分开</ span ></ article >
</ div >
< div class = "grid split" >
< article class = "card" >< div class = "card-head" >< h2 > 分片健康</ h2 >< span class = "badge success" > 全部可用</ span ></ div >< div class = "card-body health-list" >
< div class = "health-row" >< span > media-01</ span >< strong > 8 / 32</ strong >< div class = "meter" >< i style = "width:25%" ></ i ></ div ></ div >
< div class = "health-row" >< span > media-02</ span >< strong > 8 / 32</ strong >< div class = "meter" >< i style = "width:25%" ></ i ></ div ></ div >
< div class = "health-row" >< span > 推理绑定</ span >< strong > 16 / 16</ strong >< div class = "meter" >< i style = "width:100%;background:var(--violet)" ></ i ></ div ></ div >
</ div ></ article >
< article class = "card" >< div class = "card-head" >< h2 > 需要处理</ h2 >< span class = "badge warning" > 2 项</ span ></ div >< div class = "card-body" >
< p >< strong > 北围墙 03</ strong >< br >< span class = "muted" > 断线 02:14 · 正在第 4 次重连</ span ></ p >
< p >< strong > 实验楼入口 02</ strong >< br >< span class = "muted" > 时间漂移 +3.8s · 建议重新校时</ span ></ p >
</ div ></ article >
</ div >
</ section >
< section class = "view" data-page = "monitor" >
< div class = "page-head" >< div >< h1 > 实时监控</ h1 >< p > 默认只并发预览 4 路子码流;切页会释放上一页播放器。</ p ></ div >< div class = "page-actions" >< span class = "badge info" > 第 1 / 4 页</ span >< button class = "btn" > 布局 2 × 2</ button ></ div ></ div >
< div class = "grid camera-grid" id = "cameraGrid" ></ div >
</ section >
< section class = "view active" data-page = "devices" >
< div class = "page-head" >< div >< h1 > 摄像头</ h1 >< p > 期望态与实际态分开展示;连接地址和凭据始终脱敏。</ p ></ div >< div class = "page-actions" >< button class = "btn" > 扫描设备</ button >< button class = "btn btn-primary" id = "openCameraDialog" >< svg class = "icon" >< use href = "#i-plus" /></ svg > 添加摄像头</ button ></ div ></ div >
< article class = "card" >
< div class = "card-head" >< div class = "toolbar" >< div class = "search" >< svg class = "icon" >< use href = "#i-search" /></ svg >< input class = "field" aria-label = "搜索摄像头" placeholder = "名称、序列号或点位" ></ div >< select class = "select" aria-label = "设备状态筛选" >< option > 全部状态</ option >< option > 异常</ option >< option > 重连中</ option ></ select ></ div >< span class = "muted" > 已用 16 / 128</ span ></ div >
< div class = "table-wrap" >< table >< thead >< tr >< th >< input type = "checkbox" aria-label = "选择全部设备" ></ th >< th > 设备</ th >< th > 期望态</ th >< th > 实际态</ th >< th > 最后在线</ th >< th > 时间漂移</ th >< th > 媒体分片</ th >< th > 检测区域</ th >< th > 操作</ th ></ tr ></ thead >< tbody id = "deviceRows" ></ tbody ></ table ></ div >
</ article >
</ section >
< section class = "view" data-page = "device-detail" >
< div class = "context-strip" id = "ruleReturnContext" hidden >< p >< strong > 来自 Bell 规则草稿:</ strong > 已保留“非授权人员进入”的摄像头上下文;保存检测区域后可返回继续配置。</ p >< a class = "btn" id = "returnToBell" href = "../bell/index.html?view=rules&camera=CAM-S2-001&zone=ZONE-007" > 返回规则草稿</ a ></ div >
< div class = "breadcrumb" >< button id = "backToDevices" > 摄像头</ button >< span > /</ span >< span id = "deviceBreadcrumb" > 北围墙 01</ span ></ div >
< div class = "page-head" >< div >< h1 id = "deviceDetailName" > 北围墙 01</ h1 >< p >< span id = "deviceDetailSerial" class = "mono" > CAM-S2-001</ span > · 当前摄像头的接入、画面与空间配置。</ p ></ div >< div class = "page-actions" >< span class = "badge success" id = "calibrationBadge" > 区域已校准</ span >< button class = "btn" id = "simulateCalibration" > 模拟画面参数变化</ button ></ div ></ div >
< div class = "detail-tabs" role = "tablist" aria-label = "摄像头详情" >
< button role = "tab" data-detail-tab = "info" aria-selected = "false" > 基本信息</ button >
< button class = "active" role = "tab" data-detail-tab = "zones" aria-selected = "true" > 画面与检测区域</ button >
< button role = "tab" data-detail-tab = "health" aria-selected = "false" > 流与健康</ button >
< button role = "tab" data-detail-tab = "history" aria-selected = "false" > 操作记录</ button >
</ div >
< section class = "detail-panel" data-detail-panel = "info" hidden >
< div class = "grid split" >< article class = "card" >< div class = "card-head" >< h2 > 设备信息</ h2 >< span class = "badge success" > 在线</ span ></ div >< div class = "card-body" >< p >< strong > 点位:</ strong > 北围墙</ p >< p >< strong > 协议:</ strong > ONVIF / RTSP</ p >< p >< strong > 凭据:</ strong > 已安全保存,页面不可见</ p ></ div ></ article >< article class = "card" >< div class = "card-head" >< h2 > 期望态</ h2 >< span class = "badge info" > 启用</ span ></ div >< div class = "card-body" >< p > 媒体分片:media-01</ p >< p > Profile:子码流 704 × 576 · 5 FPS</ p ></ div ></ article ></ div >
</ section >
< section class = "detail-panel" data-detail-panel = "zones" >
< div class = "page-head" >< div >< h2 style = "margin:0 0 4px" > 画面与检测区域</ h2 >< p > 区域依附当前摄像头坐标系;这里只保存空间几何,不发布告警规则。</ p ></ div >< div class = "page-actions" >< span class = "badge info" id = "draftBadge" > 区域 v7</ span >< button class = "btn" > 区域版本记录</ button ></ div ></ div >
< div class = "zone-layout" >
< section class = "card zone-workspace" aria-label = "区域绘制工作区" >
< div class = "editor-tools" role = "toolbar" aria-label = "区域绘制工具" >
< button class = "tool-btn" data-tool = "pointer" aria-pressed = "false" title = "选择对象" >< svg class = "icon" >< use href = "#i-pointer" /></ svg >< span > 选择</ span ></ button >
< button class = "tool-btn active" data-tool = "polygon" aria-pressed = "true" title = "绘制多边形" >< svg class = "icon" >< use href = "#i-zone" /></ svg >< span > 多边形</ span ></ button >
< button class = "tool-btn" data-tool = "line" aria-pressed = "false" title = "绘制方向警戒线" >< svg class = "icon" >< use href = "#i-line" /></ svg >< span > 警戒线</ span ></ button >
< span class = "tool-divider" ></ span >
< button class = "tool-btn" id = "undoPoint" title = "撤销最后一个点" >< svg class = "icon" >< use href = "#i-undo" /></ svg >< span > 撤销</ span ></ button >
< button class = "tool-btn" id = "clearPoints" title = "清空当前草稿" >< svg class = "icon" >< use href = "#i-trash" /></ svg >< span > 清空</ span ></ button >
</ div >
< div class = "editor-stage" >
< svg id = "zoneCanvas" viewBox = "0 0 960 540" role = "img" aria-label = "北围墙 01 子码流快照及区域叠加;可点击添加多边形顶点" >
< defs >< linearGradient id = "sky" x1 = "0" y1 = "0" x2 = "0" y2 = "1" >< stop stop-color = "#10283b" />< stop offset = "1" stop-color = "#07111d" /></ linearGradient >< pattern id = "scan" width = "8" height = "8" patternUnits = "userSpaceOnUse" >< path d = "M0 8h8" stroke = "#ffffff" stroke-opacity = ".035" /></ pattern ></ defs >
< rect width = "960" height = "540" fill = "url(#sky)" />< path d = "M0 330 160 250 330 315 510 235 690 310 820 225 960 282V540H0Z" fill = "#13283a" />< rect y = "410" width = "960" height = "130" fill = "#101a22" />< path d = "M0 414h960M0 472h960" stroke = "#5c7489" stroke-opacity = ".45" />< path d = "M170 300v240m230-245v245m250-250v250m190-280v280" stroke = "#6f879b" stroke-width = "4" />< path d = "M0 345h960" stroke = "#7890a4" stroke-width = "5" />< rect width = "960" height = "540" fill = "url(#scan)" />
2026-08-04 00:23:55 +08:00
< polygon points = "105,365 285,310 535,335 505,466 135,472" fill = "#38bdf8" fill-opacity = ".18" stroke = "#38bdf8" stroke-width = "4" stroke-dasharray = "10 7" />
< g fill = "#07111d" stroke = "#7dd3fc" stroke-width = "4" >< circle cx = "105" cy = "365" r = "7" />< circle cx = "285" cy = "310" r = "7" />< circle cx = "535" cy = "335" r = "7" />< circle cx = "505" cy = "466" r = "7" />< circle cx = "135" cy = "472" r = "7" /></ g >
< line x1 = "650" y1 = "340" x2 = "855" y2 = "430" stroke = "#fbbf24" stroke-width = "5" />< path d = "m760 382 30-2-18 24" fill = "none" stroke = "#fbbf24" stroke-width = "5" />< g id = "draftLayer" ></ g >
< text x = "26" y = "38" fill = "#d6e8f5" font-size = "18" font-family = "sans-serif" > 北围墙 01 · 子码流预览</ text >< text x = "26" y = "66" fill = "#88a4b8" font-size = "14" font-family = "sans-serif" > CAM-S2-001 · 704 × 576 · 5 FPS</ text >
</ svg >
< div class = "editor-hint" id = "editorHint" > 多边形:点击画面添加顶点,双击闭合;也可在右侧输入坐标。</ div >
</ div >
< div class = "editor-status" >< span id = "pointStatus" > 当前草稿:0 个点</ span >< span > 缩放 100% · 网格吸附关闭</ span ></ div >
</ section >
2026-08-04 09:04:50 +08:00
< aside class = "card zone-settings" >< div class = "card-head" >< h2 > 检测区域属性</ h2 >< button class = "btn btn-quiet icon-btn" aria-label = "更多区域操作" >< svg class = "icon" >< use href = "#i-more" /></ svg ></ button ></ div >< div class = "card-body" >
2026-08-04 00:23:55 +08:00
< div class = "form-grid" >
< div class = "form-field full" >< label for = "zoneName" > 名称</ label >< input id = "zoneName" class = "field" value = "北围墙危险区域" ></ div >
< div class = "form-field" >< label for = "zoneType" > 空间类型</ label >< select id = "zoneType" class = "select" >< option > 禁入区域</ option >< option > 方向警戒线</ option >< option > 关注区域</ option ></ select ></ div >
< div class = "form-field" >< label for = "zoneColor" > 叠加颜色</ label >< select id = "zoneColor" class = "select" >< option > 蓝色</ option >< option > 琥珀色</ option >< option > 红色</ option ></ select ></ div >
</ div >
< div class = "coordinate-box" >< h3 > 键盘坐标编辑</ h3 >< p > 坐标范围 0–100%,为鼠标绘制提供等效操作。</ p >< div class = "coordinate-row" >< input class = "field" id = "coordX" type = "number" min = "0" max = "100" value = "50" aria-label = "横坐标百分比" >< input class = "field" id = "coordY" type = "number" min = "0" max = "100" value = "50" aria-label = "纵坐标百分比" >< button class = "btn" id = "addCoordinate" > 添加点</ button ></ div >< ol id = "pointList" class = "muted" aria-live = "polite" >< li > 尚未添加草稿点</ li ></ ol ></ div >
2026-08-04 09:04:50 +08:00
< div class = "linked-rules" >< h3 > 关联的告警规则</ h3 >< p > 规则由 Bell 管理;Sense 只提供摄像头快照和版本化空间坐标。</ p >< ul >< li > 非授权人员进入 · 正式生效</ li >< li > 区域内聚集 · 试运行</ li ></ ul >< a class = "btn" id = "configureRules" href = "../bell/index.html?view=rules&camera=CAM-S2-001&zone=ZONE-007" > 在 Bell 配置规则</ a ></ div >
< div class = "settings-actions" >< button class = "btn" id = "discardDraft" > 放弃修改</ button >< button class = "btn btn-primary" id = "saveDraft" > 保存区域版本</ button ></ div >
2026-08-04 00:23:55 +08:00
</ div ></ aside >
</ div >
2026-08-04 09:04:50 +08:00
</ section >
< section class = "detail-panel" data-detail-panel = "health" hidden >< div class = "grid system-grid" >< article class = "card system-tile" >< h3 > 媒体流</ h3 >< dl >< dt > 实际态</ dt >< dd >< span class = "badge success" > 在线</ span ></ dd >< dt > 分片</ dt >< dd > media-01</ dd >< dt > 重连</ dt >< dd > 0</ dd ></ dl ></ article >< article class = "card system-tile" >< h3 > 设备时间</ h3 >< dl >< dt > 漂移</ dt >< dd > +0.2s</ dd >< dt > 最近校时</ dt >< dd > 5m 前</ dd ></ dl ></ article >< article class = "card system-tile" >< h3 > 区域校准</ h3 >< dl >< dt > 坐标版本</ dt >< dd > v7</ dd >< dt > 画面参数</ dt >< dd > 匹配</ dd ></ dl ></ article ></ div ></ section >
< section class = "detail-panel" data-detail-panel = "history" hidden >< article class = "card" >< div class = "card-head" >< h2 > 操作记录</ h2 >< span class = "badge info" > 只读</ span ></ div >< div class = "table-wrap" >< table >< thead >< tr >< th > 时间</ th >< th > 操作</ th >< th > 对象</ th >< th > 执行人</ th >< th > 结果</ th ></ tr ></ thead >< tbody >< tr >< td > 22:52</ td >< td > 保存检测区域</ td >< td > ZONE-007 · v7</ td >< td > 实施工程师</ td >< td >< span class = "badge success" > 成功</ span ></ td ></ tr >< tr >< td > 22:41</ td >< td > 校验子码流参数</ td >< td > 704 × 576</ td >< td > 系统</ td >< td >< span class = "badge success" > 匹配</ span ></ td ></ tr ></ tbody ></ table ></ div ></ article ></ section >
2026-08-04 00:23:55 +08:00
</ section >
< section class = "view" data-page = "onboarding" >
< div class = "page-head" >< div >< h1 > 接入任务</ h1 >< p > 默认 16 路也支持批量导入;128 路时不依赖逐路手工操作。</ p ></ div >< div class = "page-actions" >< button class = "btn" > 下载 CSV 模板</ button ></ div ></ div >
< div class = "grid split" >
< article class = "card" >< div class = "card-head" >< h2 > 导入摄像头</ h2 >< span class = "badge info" > 配额 16 / 128</ span ></ div >< div class = "card-body" >< div class = "drop-zone" >< svg class = "icon" >< use href = "#i-upload" /></ svg >< strong > 选择 CSV 文件或拖到这里</ strong >< p > 先本地校验列、重复序列号和配额,不上传明文密码。</ p >< button class = "btn btn-primary" id = "fakeUpload" > 选择文件</ button ></ div ></ div ></ article >
< article class = "card" >< div class = "card-head" >< h2 > 当前任务</ h2 >< span class = "badge warning" > 进行中</ span ></ div >< div class = "card-body" >< p >< strong > 宿舍区第二批 · 8 路</ strong ></ p >< div class = "meter" >< i style = "width:62%" ></ i ></ div >< p class = "muted" > 已验证 5 / 8 · 2 路待补凭据 · 1 路重试中</ p >< button class = "btn" > 查看逐项结果</ button ></ div ></ article >
</ div >
</ section >
< section class = "view" data-page = "system" >
< div class = "page-head" >< div >< h1 > 系统状态</ h1 >< p > MediaMTX、数据库和对账器只是可观察组件,不替代设备台账真相。</ p ></ div >< div class = "page-actions" >< button class = "btn" >< svg class = "icon" >< use href = "#i-refresh" /></ svg > 刷新</ button ></ div ></ div >
< div class = "grid system-grid" >
< article class = "card system-tile" >< h3 > Sense 服务</ h3 >< dl >< dt > 状态</ dt >< dd >< span class = "badge success" > 健康</ span ></ dd >< dt > 运行时间</ dt >< dd > 12d 06h</ dd >< dt > 待处理队列</ dt >< dd > 2</ dd ></ dl ></ article >
< article class = "card system-tile" >< h3 > MediaMTX</ h3 >< dl >< dt > 实例</ dt >< dd > 2 / 2</ dd >< dt > Readers</ dt >< dd > 15</ dd >< dt > 重连</ dt >< dd > 1</ dd ></ dl ></ article >
< article class = "card system-tile" >< h3 > 本地资源</ h3 >< dl >< dt > CPU</ dt >< dd > 22%</ dd >< dt > 内存</ dt >< dd > 4.8 / 16 GB</ dd >< dt > 事件缓存</ dt >< dd > 86 MB</ dd ></ dl ></ article >
</ div >
</ section >
</ div >
</ main >
</ div >
< nav class = "mobile-nav" aria-label = "移动端主导航" >
2026-08-04 09:04:50 +08:00
< button data-view = "overview" > 总览</ button >< button data-view = "monitor" > 监控</ button >< button class = "active" data-view = "devices" > 设备</ button >< button data-view = "onboarding" > 接入</ button >< button data-view = "system" > 系统</ button >
2026-08-04 00:23:55 +08:00
</ nav >
< dialog id = "cameraDialog" aria-labelledby = "cameraDialogTitle" >
< form method = "dialog" >
< div class = "dialog-head" >< h2 id = "cameraDialogTitle" > 添加摄像头</ h2 >< button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭对话框" > × </ button ></ div >
< div class = "dialog-body" >< div class = "form-grid" >
< div class = "form-field" >< label for = "cameraName" > 名称</ label >< input class = "field" id = "cameraName" required value = "北围墙 04" ></ div >
< div class = "form-field" >< label for = "cameraProtocol" > 协议</ label >< select class = "select" id = "cameraProtocol" >< option > ONVIF</ option >< option > RTSP</ option ></ select ></ div >
< div class = "form-field full" >< label for = "cameraEndpoint" > 设备地址</ label >< input class = "field" id = "cameraEndpoint" value = "192.0.2.24:80" aria-describedby = "endpointHelp" >< small id = "endpointHelp" class = "muted" > 原型使用保留地址;保存后列表仅显示脱敏端点。</ small ></ div >
< div class = "form-field" >< label for = "cameraUser" > 用户名</ label >< input class = "field" id = "cameraUser" value = "operator" ></ div >
< div class = "form-field" >< label for = "cameraPassword" > 密码</ label >< input class = "field" id = "cameraPassword" type = "password" value = "prototype-only" ></ div >
< div class = "form-field" >< label for = "cameraArea" > 区域</ label >< select class = "select" id = "cameraArea" >< option > 北围墙</ option >< option > 实验楼</ option >< option > 宿舍区</ option ></ select ></ div >
< div class = "form-field" >< label for = "cameraSerial" > 序列号</ label >< input class = "field" id = "cameraSerial" value = "CAM-DEMO-004" ></ div >
</ div >< p >< button type = "button" class = "btn" id = "testConnection" > 测试连接</ button > < span id = "testResult" class = "muted" aria-live = "polite" > 保存前先验证 Profiles、StreamUri 与校时能力。</ span ></ p ></ div >
< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 取消</ button >< button class = "btn btn-primary" id = "saveCamera" value = "default" > 保存为待激活</ button ></ div >
</ form >
</ dialog >
< div class = "toast" id = "toast" role = "status" aria-live = "polite" ></ div >
< script >
const $ = ( selector , root = document ) => root . querySelector ( selector );
const $$ = ( selector , root = document ) => [... root . querySelectorAll ( selector )];
const toast = $ ( '#toast' );
let toastTimer ;
function notify ( message ) { toast . textContent = message ; toast . classList . add ( 'show' ); clearTimeout ( toastTimer ); toastTimer = setTimeout (() => toast . classList . remove ( 'show' ), 3600 ); }
const deviceData = [
2026-08-04 09:04:50 +08:00
[ '北围墙 01' , 'CAM-S2-001' , '启用' , '在线' , '刚刚' , '+0.2s' , 'media-01' , 'success' , '2 个区域' , 'success' ],
[ '北围墙 03' , 'CAM-S2-003' , '启用' , '重连中' , '2 分钟前' , '—' , 'media-01' , 'warning' , '待校准' , 'warning' ],
[ '实验楼入口 02' , 'CAM-S2-006' , '启用' , '在线' , '12 秒前' , '+3.8s' , 'media-02' , 'warning' , '1 个区域' , 'success' ],
[ '宿舍东通道' , 'CAM-S2-011' , '启用' , '在线' , '4 秒前' , '+0.1s' , 'media-02' , 'success' , '3 个区域' , 'success' ],
[ '配电房门口' , 'CAM-S2-015' , '停用' , '已停止' , '1 天前' , '—' , '未绑定' , 'info' , '未配置' , 'info' ]
2026-08-04 00:23:55 +08:00
];
2026-08-04 09:04:50 +08:00
$ ( '#deviceRows' ). innerHTML = deviceData . map ( d => `<tr><td><input type="checkbox" aria-label="选择 ${ d [ 0 ] } "></td><td><div class="device-name"><span class="camera-icon"><svg class="icon"><use href="#i-camera"/></svg></span><span><strong> ${ d [ 0 ] } </strong><small> ${ d [ 1 ] } </small></span></div></td><td> ${ d [ 2 ] } </td><td><span class="badge ${ d [ 7 ] } "> ${ d [ 3 ] } </span></td><td> ${ d [ 4 ] } </td><td> ${ d [ 5 ] } </td><td> ${ d [ 6 ] } </td><td><span class="badge ${ d [ 9 ] } "> ${ d [ 8 ] } </span></td><td><button class="btn" data-open-camera=" ${ d [ 1 ] } ">查看详情</button></td></tr>` ). join ( '' );
2026-08-04 00:23:55 +08:00
const cameraNames = [ '北围墙 01' , '实验楼入口 02' , '宿舍东通道' , '配电房门口' ];
$ ( '#cameraGrid' ). innerHTML = cameraNames . map (( name , i ) => `<article class="card camera-card"><div class="camera-stage"><svg class="scene" viewBox="0 0 640 360" role="img" aria-label=" ${ name } 模拟子码流画面"><rect width="640" height="360" fill="#07111d"/><path d="M0 ${ 250 - i * 12 } 140 180 310 230 460 150 640 210V360H0Z" fill="#14283a"/><path d="M0 290h640M100 210v150m210-165v165m220-190v190" stroke="#647b8e" stroke-width="4"/><rect width="640" height="360" fill="none" stroke="#ffffff" stroke-opacity=".08"/><text x="18" y="28" fill="#d7e7f3" font-size="16"> ${ name } · 子码流</text><circle cx="598" cy="27" r="5" fill="#34d399"/></svg></div><div class="camera-meta"><span><strong> ${ name } </strong><small>704 × 576 · 5 FPS</small></span><span class="badge success">在线</span></div></article>` ). join ( '' );
function switchView ( name ) {
$$ ( '.view' ). forEach ( v => v . classList . toggle ( 'active' , v . dataset . page === name ));
2026-08-04 09:04:50 +08:00
const navName = name === 'device-detail' ? 'devices' : name ;
$$ ( '[data-view]' ). forEach ( b => { const current = b . dataset . view === navName ; b . toggleAttribute ( 'aria-current' , current ); b . classList . toggle ( 'active' , current ); });
2026-08-04 00:23:55 +08:00
$ ( '#stateSelect' ). value = 'normal' ; renderState ( 'normal' );
$ ( '#main-content' ). focus ({ preventScroll : true });
}
$$ ( '[data-view]' ). forEach ( b => b . addEventListener ( 'click' , () => switchView ( b . dataset . view )));
2026-08-04 09:04:50 +08:00
function openDeviceDetail ( serial = 'CAM-S2-001' ) {
const device = deviceData . find ( d => d [ 1 ] === serial ) || deviceData [ 0 ];
$ ( '#deviceDetailName' ). textContent = device [ 0 ]; $ ( '#deviceBreadcrumb' ). textContent = device [ 0 ]; $ ( '#deviceDetailSerial' ). textContent = device [ 1 ];
const bellHref = `../bell/index.html?view=rules&camera= ${ encodeURIComponent ( device [ 1 ]) } &zone=ZONE-007` ;
$ ( '#configureRules' ). href = bellHref ; $ ( '#returnToBell' ). href = bellHref ;
switchView ( 'device-detail' );
}
$$ ( '[data-open-camera]' ). forEach ( b => b . addEventListener ( 'click' , () => openDeviceDetail ( b . dataset . openCamera )));
$ ( '#backToDevices' ). addEventListener ( 'click' , () => switchView ( 'devices' ));
$$ ( '.detail-tabs button' ). forEach ( button => button . addEventListener ( 'click' , () => {
const name = button . dataset . detailTab ;
$$ ( '.detail-tabs button' ). forEach ( b => { const on = b === button ; b . classList . toggle ( 'active' , on ); b . setAttribute ( 'aria-selected' , on ); });
$$ ( '[data-detail-panel]' ). forEach ( panel => panel . hidden = panel . dataset . detailPanel !== name );
}));
2026-08-04 00:23:55 +08:00
function renderState ( state ) {
const layer = $ ( '#stateLayer' ); const views = $$ ( '.view' );
layer . classList . toggle ( 'show' , state !== 'normal' ); views . forEach ( v => v . style . display = state === 'normal' ? '' : 'none' );
if ( state === 'normal' ) return ;
const configs = {
loading : `<div><div class="skeleton" aria-label="正在加载"><i></i><i></i><i></i><i></i></div><h2>正在同步边缘状态</h2><p>超过 300ms 时显示进度;已有视频流不因控制面加载而中断。</p></div>` ,
empty : `<div><svg class="icon"><use href="#i-camera"/></svg><h2>这个站点还没有摄像头</h2><p>先扫描 ONVIF 设备,或下载 CSV 模板进行批量导入。</p><button class="btn btn-primary" onclick="switchView('onboarding')">开始接入</button></div>` ,
error : `<div><svg class="icon" style="color:var(--danger)"><use href="#i-alert"/></svg><h2>无法读取最新状态</h2><p>边缘节点暂时不可达。当前显示可能已过期,系统不会把未知状态伪装为在线。</p><button class="btn" onclick="document.querySelector('#stateSelect').value='normal';renderState('normal')">重试</button></div>`
}; layer . innerHTML = configs [ state ];
}
$ ( '#stateSelect' ). addEventListener ( 'change' , e => renderState ( e . target . value ));
$ ( '#themeToggle' ). addEventListener ( 'click' , () => { const html = document . documentElement ; html . dataset . theme = html . dataset . theme === 'dark' ? 'light' : 'dark' ; notify ( `已切换为 ${ html . dataset . theme === 'dark' ? '深色' : '浅色' } 主题` ); });
const zoneCanvas = $ ( '#zoneCanvas' ), draftLayer = $ ( '#draftLayer' ), points = [];
let activeTool = 'polygon' ;
function drawDraft () {
const coords = points . map ( p => ` ${ p . x } , ${ p . y } ` ). join ( ' ' );
const shape = activeTool === 'line' && points . length > 1 ? `<line x1=" ${ points [ 0 ]. x } " y1=" ${ points [ 0 ]. y } " x2=" ${ points [ 1 ]. x } " y2=" ${ points [ 1 ]. y } " stroke="#fbbf24" stroke-width="5"/>` : `<polyline points=" ${ coords } " fill=" ${ points . length > 2 ? 'rgba(167,139,250,.18)' : 'none' } " stroke="#a78bfa" stroke-width="4" stroke-dasharray="8 6"/>` ;
draftLayer . innerHTML = shape + points . map (( p , i ) => `<circle cx=" ${ p . x } " cy=" ${ p . y } " r="8" fill="#08111f" stroke="#c4b5fd" stroke-width="4"><title>点 ${ i + 1 } </title></circle>` ). join ( '' );
$ ( '#pointStatus' ). textContent = `当前草稿: ${ points . length } 个点` ;
$ ( '#pointList' ). innerHTML = points . length ? points . map (( p , i ) => `<li>点 ${ i + 1 } : ${ ( p . x / 9.6 ). toFixed ( 1 ) } %, ${ ( p . y / 5.4 ). toFixed ( 1 ) } %</li>` ). join ( '' ) : '<li>尚未添加草稿点</li>' ;
2026-08-04 09:04:50 +08:00
$ ( '#draftBadge' ). className = points . length ? 'badge warning' : 'badge info' ;
$ ( '#draftBadge' ). textContent = points . length ? '区域有未保存修改' : '区域 v7' ;
2026-08-04 00:23:55 +08:00
}
function addPoint ( x , y ) { if ( activeTool === 'pointer' ) return ; if ( activeTool === 'line' && points . length >= 2 ) points . length = 0 ; points . push ({ x : Math . max ( 0 , Math . min ( 960 , x )), y : Math . max ( 0 , Math . min ( 540 , y ))}); drawDraft (); }
zoneCanvas . addEventListener ( 'click' , e => { const r = zoneCanvas . getBoundingClientRect (); addPoint (( e . clientX - r . left ) / r . width * 960 ,( e . clientY - r . top ) / r . height * 540 ); });
zoneCanvas . addEventListener ( 'dblclick' , e => { e . preventDefault (); if ( activeTool === 'polygon' && points . length >= 3 ) notify ( `多边形已闭合,共 ${ points . length } 个顶点;请保存草稿。` ); });
$$ ( '.tool-btn[data-tool]' ). forEach ( btn => btn . addEventListener ( 'click' , () => { activeTool = btn . dataset . tool ; $$ ( '.tool-btn[data-tool]' ). forEach ( b => { const on = b === btn ; b . classList . toggle ( 'active' , on ); b . setAttribute ( 'aria-pressed' , on ); }); points . length = 0 ; drawDraft (); $ ( '#editorHint' ). textContent = activeTool === 'line' ? '警戒线:依次添加起点与终点;箭头方向由起点指向终点。' : activeTool === 'pointer' ? '选择:查看已有区域;切换绘制工具可新建草稿。' : '多边形:点击画面添加顶点,双击闭合;也可在右侧输入坐标。' ; }));
$ ( '#undoPoint' ). addEventListener ( 'click' , () => { points . pop (); drawDraft (); });
$ ( '#clearPoints' ). addEventListener ( 'click' , () => { if ( points . length && confirm ( '清空当前未保存的绘制草稿?' )) { points . length = 0 ; drawDraft (); } });
$ ( '#addCoordinate' ). addEventListener ( 'click' , () => { const x = Number ( $ ( '#coordX' ). value ), y = Number ( $ ( '#coordY' ). value ); if ( ! Number . isFinite ( x ) ||! Number . isFinite ( y ) || x < 0 || x > 100 || y < 0 || y > 100 ) { notify ( '坐标必须在 0– 100% 之间。' ); return ; } addPoint ( x * 9.6 , y * 5.4 ); });
2026-08-04 09:04:50 +08:00
$ ( '#discardDraft' ). addEventListener ( 'click' , () => { if ( ! points . length || confirm ( '放弃未保存的区域修改?' )) { points . length = 0 ; drawDraft (); notify ( '已放弃本次修改,已保存区域和 Bell 规则均未改变。' ); } });
$ ( '#saveDraft' ). addEventListener ( 'click' , () => { if (( activeTool === 'polygon' && points . length < 3 ) || ( activeTool === 'line' && points . length < 2 )) { notify ( activeTool === 'line' ? '警戒线至少需要 2 个点。' : '多边形至少需要 3 个点。' ); return ; } points . length = 0 ; drawDraft (); $ ( '#draftBadge' ). className = 'badge success' ; $ ( '#draftBadge' ). textContent = '区域 v8 已保存' ; notify ( '检测区域 v8 已保存;关联的 Bell 规则不会被自动发布。' ); });
$ ( '#simulateCalibration' ). addEventListener ( 'click' , () => { $ ( '#calibrationBadge' ). className = 'badge warning' ; $ ( '#calibrationBadge' ). textContent = '区域待校准' ; notify ( '画面分辨率或旋转发生变化,现有区域已标记为待校准。' ); });
2026-08-04 00:23:55 +08:00
const dialog = $ ( '#cameraDialog' );
$ ( '#openCameraDialog' ). addEventListener ( 'click' , () => dialog . showModal ());
$ ( '#testConnection' ). addEventListener ( 'click' , async e => { const b = e . currentTarget ; b . disabled = true ; b . textContent = '测试中…' ; $ ( '#testResult' ). textContent = '正在读取 Profiles 与设备时间' ; await new Promise ( r => setTimeout ( r , 700 )); b . disabled = false ; b . textContent = '重新测试' ; $ ( '#testResult' ). innerHTML = '<span style="color:var(--success)">连接成功:2 个 Profile,时间漂移 +0.4s</span>' ; });
$ ( '#saveCamera' ). addEventListener ( 'click' , () => notify ( '摄像头已保存为“待激活”,凭据不会显示在列表中。' ));
$ ( '#fakeUpload' ). addEventListener ( 'click' , () => notify ( '演示:已选择 cameras-demo.csv,检测到 1 个重复序列号。' ));
drawDraft ();
2026-08-04 09:04:50 +08:00
const query = new URLSearchParams ( location . search );
if ( query . get ( 'view' ) === 'device-detail' || query . has ( 'camera' )) {
openDeviceDetail ( query . get ( 'camera' ) || 'CAM-S2-001' );
if ( query . get ( 'return' ) === 'bell-rule' ) $ ( '#ruleReturnContext' ). hidden = false ;
}
2026-08-04 00:23:55 +08:00
</ script >
</ body >
</ html >