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 Bell · 预警处置原型</ title >
< style >
: root {
--bg : #090d18 ;
--surface : #101725 ;
--surface-2 : #151e2e ;
--surface-3 : #1b2638 ;
--text : #f5f7fb ;
--muted : #a3b0c2 ;
--subtle : #73839a ;
--line : #29364a ;
--primary : #8b7cf6 ;
--primary-strong : #7161e8 ;
--primary-soft : rgba ( 139 , 124 , 246 , .14 );
--cyan : #38bdf8 ;
--success : #34d399 ;
--warning : #fbbf24 ;
--danger : #fb7185 ;
--shadow : 0 18 px 46 px rgba ( 0 , 0 , 0 , .25 );
--radius : 14 px ;
--sidebar : 216 px ;
--banner : 28 px ;
}
html [ data-theme = "light" ] {
--bg : #f1f4f8 ;
--surface : #ffffff ;
--surface-2 : #f8fafc ;
--surface-3 : #eaf0f6 ;
--text : #131d2c ;
--muted : #52647a ;
--subtle : #738096 ;
--line : #d2dbe6 ;
--primary-soft : rgba ( 113 , 97 , 232 , .1 );
--shadow : 0 14 px 34 px rgba ( 32 , 52 , 78 , .12 );
}
* { box-sizing : border-box ; }
2026-08-04 11:08:41 +08:00
[ hidden ] { display : none !important ; }
2026-08-04 00:23:55 +08:00
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 ( 139 , 124 , 246 , .45 ); outline-offset : 2 px ; }
. prototype-banner { position : fixed ; z-index : 1000 ; inset : 0 0 auto 0 ; 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 ); z-index : 30 ; height : calc ( 100 dvh - var ( -- banner )); padding : 18 px 12 px ; border-right : 1 px solid var ( -- line ); background : var ( -- surface ); display : flex ; flex-direction : column ; }
. 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 , #8b7cf6 , #38bdf8 ); color : white ; font-weight : 900 ; box-shadow : 0 8 px 22 px rgba ( 113 , 97 , 232 , .25 ); }
. brand strong { display : block ; font-size : 16 px ; }
. brand small { color : var ( -- muted ); }
. nav-label { margin : 8 px 10 px 6 px ; color : var ( -- subtle ); font-size : 11 px ; font-weight : 800 ; 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 : .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 ; }
. shift-card { margin-top : auto ; padding : 12 px ; border : 1 px solid var ( -- line ); border-radius : 11 px ; background : var ( -- surface -2 ); }
. shift-card strong { display : block ; }
. shift-card span { color : var ( -- muted ); font-size : 12 px ; }
. shift-bar { margin-top : 9 px ; height : 5 px ; border-radius : 99 px ; background : var ( -- surface -3 ); overflow : hidden ; }
. shift-bar i { display : block ; width : 63 % ; height : 100 % ; background : var ( -- success ); }
. main { min-width : 0 ; }
. topbar { position : sticky ; top : var ( -- banner ); z-index : 20 ; min-height : 64 px ; padding : 10 px 20 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 strong { display : block ; font-size : 15 px ; }
. site span { color : var ( -- muted ); font-size : 12 px ; }
. top-actions { margin-left : auto ; display : flex ; gap : 8 px ; align-items : center ; }
2026-08-04 09:04:52 +08:00
. 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 ); display : inline-flex ; align-items : center ; justify-content : center ; gap : 8 px ; font-weight : 750 ; text-decoration : none ; transition : .2 s ease ; }
2026-08-04 00:23:55 +08:00
. btn : hover { border-color : var ( -- primary ); }
. btn : disabled { cursor : not-allowed ; opacity : .5 ; }
. btn-primary { background : var ( -- primary - strong ); border-color : var ( -- primary - strong ); color : white ; }
. btn-success { background : #117a5d ; border-color : #159271 ; color : white ; }
. btn-danger { background : rgba ( 251 , 113 , 133 , .1 ); border-color : rgba ( 251 , 113 , 133 , .38 ); color : #ffb4c2 ; }
. btn-quiet { border-color : transparent ; background : transparent ; color : var ( -- muted ); }
. icon-btn { width : 42 px ; padding : 0 ; }
2026-08-04 11:49:18 +08:00
. text-button { min-height : 32 px ; padding : 2 px 6 px ; border : 0 ; background : transparent ; color : #b8e5ff ; text-decoration : underline ; text-underline-offset : 3 px ; }
2026-08-04 00:23:55 +08:00
. select , . field , . textarea { width : 100 % ; min-height : 40 px ; padding : 8 px 11 px ; border : 1 px solid var ( -- line ); border-radius : 9 px ; background : var ( -- surface -2 ); color : var ( -- text ); }
. textarea { min-height : 80 px ; resize : vertical ; }
. top-actions . select { width : auto ; min-width : 150 px ; }
. content { padding : 18 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 { margin-bottom : 15 px ; display : flex ; align-items : flex-start ; justify-content : space-between ; gap : 16 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 , . toolbar { display : flex ; align-items : center ; flex-wrap : wrap ; gap : 8 px ; }
. card { border : 1 px solid var ( -- line ); border-radius : var ( -- radius ); background : var ( -- surface ); box-shadow : var ( -- shadow ); }
. card-head { min-height : 52 px ; padding : 11 px 14 px ; border-bottom : 1 px solid var ( -- line ); display : flex ; align-items : center ; justify-content : space-between ; gap : 10 px ; }
. card-head h2 , . card-head h3 { margin : 0 ; font-size : 15 px ; }
. card-body { padding : 14 px ; }
. badge { display : inline-flex ; align-items : center ; gap : 5 px ; min-height : 24 px ; padding : 2 px 8 px ; border : 1 px solid var ( -- line ); border-radius : 999 px ; color : var ( -- muted ); font-size : 12 px ; white-space : nowrap ; }
. badge . success { color : #78edc2 ; border-color : rgba ( 52 , 211 , 153 , .34 ); background : rgba ( 52 , 211 , 153 , .1 ); }
. badge . warning { color : #ffe18a ; border-color : rgba ( 251 , 191 , 36 , .36 ); background : rgba ( 251 , 191 , 36 , .1 ); }
. badge . danger { color : #ff9eb0 ; border-color : rgba ( 251 , 113 , 133 , .4 ); background : rgba ( 251 , 113 , 133 , .1 ); }
. badge . info { color : #a5e2ff ; border-color : rgba ( 56 , 189 , 248 , .35 ); background : rgba ( 56 , 189 , 248 , .1 ); }
. badge . violet { color : #c9c1ff ; border-color : rgba ( 139 , 124 , 246 , .38 ); background : var ( -- primary - soft ); }
. duty-grid { display : grid ; grid-template-columns : 320 px minmax ( 420 px , 1 fr ) 330 px ; gap : 12 px ; min-height : calc ( 100 dvh - 156 px ); }
. queue , . evidence , . action-panel { min-width : 0 ; overflow : hidden ; }
. queue-head { padding : 10 px ; border-bottom : 1 px solid var ( -- line ); }
. queue-tabs { display : grid ; grid-template-columns : repeat ( 3 , 1 fr ); gap : 4 px ; padding : 4 px ; border-radius : 9 px ; background : var ( -- surface -2 ); }
. queue-tabs button { min-height : 34 px ; border : 0 ; border-radius : 7 px ; background : transparent ; color : var ( -- muted ); }
. queue-tabs button . active { background : var ( -- surface -3 ); color : var ( -- text ); }
. alert-list { padding : 8 px ; display : grid ; gap : 7 px ; max-height : calc ( 100 dvh - 265 px ); overflow-y : auto ; }
. alert-card { width : 100 % ; padding : 11 px ; border : 1 px solid transparent ; border-radius : 10 px ; background : transparent ; color : var ( -- text ); text-align : left ; transition : .18 s ease ; }
. alert-card : hover { background : var ( -- surface -2 ); }
. alert-card . selected { background : var ( -- primary - soft ); border-color : rgba ( 139 , 124 , 246 , .45 ); }
. alert-card-top , . alert-card-foot { display : flex ; align-items : center ; justify-content : space-between ; gap : 8 px ; }
. alert-card h3 { margin : 8 px 0 3 px ; font-size : 14 px ; }
. alert-card p { margin : 0 ; color : var ( -- muted ); font-size : 12 px ; }
. alert-card-foot { margin-top : 9 px ; color : var ( -- subtle ); font-size : 12 px ; }
. severity-dot { width : 8 px ; height : 8 px ; border-radius : 50 % ; background : currentColor ; }
. evidence-summary { padding : 12 px 14 px ; display : flex ; align-items : flex-start ; justify-content : space-between ; gap : 12 px ; border-bottom : 1 px solid var ( -- line ); }
. evidence-summary h2 { margin : 0 0 5 px ; font-size : 18 px ; }
. evidence-summary p { margin : 0 ; color : var ( -- muted ); }
. evidence-tabs { display : flex ; gap : 4 px ; padding : 8 px 12 px 0 ; }
. evidence-tabs button { min-height : 36 px ; padding : 0 12 px ; border : 0 ; border-radius : 8 px 8 px 0 0 ; background : transparent ; color : var ( -- muted ); }
. evidence-tabs button . active { background : var ( -- surface -2 ); color : var ( -- text ); }
. video-stage { position : relative ; aspect-ratio : 16 / 9 ; margin : 0 12 px 12 px ; background : #02060d ; overflow : hidden ; border : 1 px solid var ( -- line ); border-radius : 10 px ; }
. video-stage svg { width : 100 % ; height : 100 % ; display : block ; }
. video-controls { position : absolute ; inset : auto 0 0 ; min-height : 44 px ; padding : 6 px 10 px ; display : flex ; align-items : center ; gap : 9 px ; color : white ; background : linear-gradient ( transparent , rgba ( 0 , 0 , 0 , .88 )); }
. video-controls button { width : 34 px ; height : 34 px ; border : 0 ; border-radius : 7 px ; background : rgba ( 255 , 255 , 255 , .08 ); color : white ; }
. timeline-bar { flex : 1 ; height : 5 px ; border-radius : 99 px ; background : rgba ( 255 , 255 , 255 , .22 ); overflow : hidden ; }
. timeline-bar i { display : block ; width : 54 % ; height : 100 % ; background : var ( -- primary ); }
. fact-grid { margin : 0 12 px 12 px ; display : grid ; grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr )); gap : 8 px ; }
. fact { padding : 10 px ; border : 1 px solid var ( -- line ); border-radius : 9 px ; background : var ( -- surface -2 ); }
. fact span { display : block ; color : var ( -- muted ); font-size : 11 px ; }
. fact strong { display : block ; margin-top : 3 px ; font-size : 13 px ; font-variant-numeric : tabular-nums ; }
. privacy-note { margin : 0 12 px 12 px ; padding : 9 px 10 px ; display : flex ; gap : 8 px ; border-left : 3 px solid var ( -- cyan ); background : rgba ( 56 , 189 , 248 , .08 ); color : var ( -- muted ); font-size : 12 px ; }
2026-08-04 11:49:18 +08:00
. relation-panel { margin : 0 12 px 12 px ; padding : 11 px ; border : 1 px solid var ( -- line ); border-radius : 10 px ; background : var ( -- surface -2 ); }
. relation-panel h3 { margin : 0 0 8 px ; font-size : 13 px ; }
. relation-list { display : grid ; gap : 7 px ; }
. relation-item { min-height : 40 px ; padding : 8 px 10 px ; border : 1 px solid var ( -- line ); border-radius : 8 px ; background : var ( -- surface ); color : var ( -- text ); display : flex ; align-items : center ; justify-content : space-between ; gap : 10 px ; text-align : left ; }
. relation-item span { color : var ( -- muted ); font-size : 12 px ; }
2026-08-04 00:23:55 +08:00
. action-scroll { max-height : calc ( 100 dvh - 210 px ); overflow-y : auto ; }
. ack-box { padding : 14 px ; border-bottom : 1 px solid var ( -- line ); }
. ack-box . btn { width : 100 % ; min-height : 48 px ; }
. ack-box p { margin : 9 px 0 0 ; color : var ( -- muted ); font-size : 12 px ; }
2026-08-04 11:49:18 +08:00
. ack-tools { margin-top : 8 px ; display : flex ; justify-content : flex-end ; }
. inline-alert { margin-top : 9 px ; padding : 9 px 10 px ; border : 1 px solid rgba ( 251 , 191 , 36 , .4 ); border-radius : 8 px ; background : rgba ( 251 , 191 , 36 , .09 ); color : #ffe8a8 ; font-size : 12 px ; }
. inline-alert . danger { border-color : rgba ( 251 , 113 , 133 , .42 ); background : rgba ( 251 , 113 , 133 , .09 ); color : #ffc3ce ; }
2026-08-04 00:23:55 +08:00
. countdown { display : grid ; grid-template-columns : auto 1 fr ; gap : 10 px ; align-items : center ; padding : 12 px ; margin : 12 px ; border : 1 px solid rgba ( 251 , 191 , 36 , .28 ); border-radius : 10 px ; background : rgba ( 251 , 191 , 36 , .07 ); }
. countdown strong { display : block ; font-size : 20 px ; color : #ffe18a ; font-variant-numeric : tabular-nums ; }
. countdown span { color : var ( -- muted ); font-size : 12 px ; }
. escalation { padding : 0 14 px 14 px ; }
. escalation h3 { margin : 0 0 10 px ; font-size : 14 px ; }
. step { position : relative ; padding : 0 0 16 px 27 px ; }
. step :: before { content : "" ; position : absolute ; left : 7 px ; top : 17 px ; bottom : 0 ; width : 1 px ; background : var ( -- line ); }
. step : last-child :: before { display : none ; }
. step-dot { position : absolute ; left : 0 ; top : 3 px ; width : 15 px ; height : 15 px ; border-radius : 50 % ; border : 3 px solid var ( -- surface ); background : var ( -- subtle ); box-shadow : 0 0 0 1 px var ( -- line ); }
. step . done . step-dot { background : var ( -- success ); }
. step . current . step-dot { background : var ( -- warning ); }
. step strong { display : block ; }
. step span { color : var ( -- muted ); font-size : 12 px ; }
2026-08-04 11:49:18 +08:00
. delivery-facts { margin-top : 4 px ; display : grid ; gap : 2 px ; }
. delivery-facts span { display : block ; }
2026-08-04 00:23:55 +08:00
. disposition { padding : 14 px ; border-top : 1 px solid var ( -- line ); display : grid ; gap : 9 px ; }
. disposition label { color : var ( -- muted ); font-size : 12 px ; font-weight : 700 ; }
. disposition-actions { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 8 px ; }
. grid { display : grid ; gap : 12 px ; }
. stats { grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr )); margin-bottom : 12 px ; }
. stat { padding : 15 px ; min-height : 110 px ; display : flex ; flex-direction : column ; justify-content : space-between ; }
. stat span { color : var ( -- muted ); }
. stat strong { font-size : 27 px ; font-variant-numeric : tabular-nums ; }
. search { position : relative ; min-width : 230 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 ; }
table { width : 100 % ; border-collapse : collapse ; min-width : 870 px ; }
th , td { padding : 12 px 14 px ; border-bottom : 1 px solid var ( -- line ); text-align : left ; vertical-align : middle ; }
th { background : var ( -- surface -2 ); color : var ( -- muted ); font-size : 12 px ; }
td { font-variant-numeric : tabular-nums ; }
tr : last-child td { border-bottom : 0 ; }
tr : hover td { background : color-mix ( in srgb , var ( -- primary - soft ) 50 % , transparent ); }
2026-08-04 11:49:18 +08:00
. pager { min-height : 58 px ; padding : 10 px 14 px ; border-top : 1 px solid var ( -- line ); display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; }
. pager-actions { display : flex ; gap : 7 px ; }
2026-08-04 00:23:55 +08:00
. rule-grid { grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr )); }
. rule-card { padding : 15 px ; }
. rule-card-top { display : flex ; align-items : center ; justify-content : space-between ; gap : 8 px ; }
. rule-card h2 { margin : 12 px 0 5 px ; font-size : 16 px ; }
. rule-card p { margin : 0 0 14 px ; color : var ( -- muted ); }
. rule-card dl { margin : 0 ; display : grid ; grid-template-columns : 1 fr auto ; gap : 7 px ; color : var ( -- muted ); }
. rule-card dd { margin : 0 ; color : var ( -- text ); }
2026-08-04 09:04:52 +08:00
. rule-card-actions { margin-top : 14 px ; padding-top : 12 px ; display : flex ; flex-wrap : wrap ; gap : 7 px ; border-top : 1 px solid var ( -- line ); }
. rule-context { 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 ( 56 , 189 , 248 , .34 ); border-radius : 10 px ; background : rgba ( 56 , 189 , 248 , .08 ); }
. rule-context [ hidden ] { display : none ; }
. rule-context p { margin : 0 ; color : var ( -- muted ); }
2026-08-04 11:08:41 +08:00
. site-grid { grid-template-columns : minmax ( 260 px , .72 fr ) minmax ( 520 px , 1.45 fr ); }
. site-list { padding : 8 px ; display : grid ; gap : 7 px ; }
. site-item { width : 100 % ; min-height : 62 px ; padding : 10 px 12 px ; border : 1 px solid var ( -- line ); border-radius : 9 px ; background : var ( -- surface -2 ); color : var ( -- text ); display : flex ; justify-content : space-between ; align-items : center ; gap : 10 px ; text-align : left ; }
. site-item . active { border-color : var ( -- primary ); background : var ( -- primary - soft ); }
. site-item small { display : block ; color : var ( -- muted ); }
. policy-note { margin : 12 px 14 px 0 ; padding : 10 px 12 px ; border-left : 3 px solid var ( -- warning ); background : rgba ( 251 , 191 , 36 , .08 ); color : var ( -- muted ); }
. context-strip { margin-bottom : 12 px ; padding : 11 px 13 px ; border : 1 px solid rgba ( 56 , 189 , 248 , .34 ); border-radius : 10 px ; background : rgba ( 56 , 189 , 248 , .08 ); display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; }
. context-strip p { margin : 0 ; color : var ( -- muted ); }
2026-08-04 09:04:52 +08:00
. linked-zone { grid-column : 1 /- 1 ; padding : 11 px ; display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; border : 1 px solid var ( -- line ); border-radius : 10 px ; background : var ( -- surface -2 ); }
. linked-zone strong , . linked-zone small { display : block ; }
. linked-zone small { color : var ( -- muted ); }
. workflow-note { grid-column : 1 /- 1 ; margin : 0 ; padding : 10 px 11 px ; border-left : 3 px solid var ( -- primary ); background : var ( -- primary - soft ); color : var ( -- muted ); font-size : 12 px ; }
2026-08-04 11:49:18 +08:00
. version-diff { display : grid ; gap : 8 px ; }
. version-diff article { padding : 10 px 12 px ; border : 1 px solid var ( -- line ); border-radius : 9 px ; background : var ( -- surface -2 ); }
. version-diff article strong { display : block ; }
2026-08-04 14:14:06 +08:00
. chain-subnav { margin-bottom : 12 px ; padding : 4 px ; display : grid ; grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr )); gap : 4 px ; border : 1 px solid var ( -- line ); border-radius : 11 px ; background : var ( -- surface ); }
. chain-subnav button { min-height : 44 px ; border : 0 ; border-radius : 8 px ; background : transparent ; color : var ( -- muted ); font-weight : 750 ; }
. chain-subnav button . active { background : var ( -- primary - soft ); color : var ( -- text ); box-shadow : inset 0 0 0 1 px rgba ( 139 , 124 , 246 , .36 ); }
. chain-panel { display : none ; }
. chain-panel . active { display : block ; }
2026-08-04 00:23:55 +08:00
. chain-grid { grid-template-columns : minmax ( 0 , 1.4 fr ) minmax ( 300 px , .8 fr ); }
. chain-row { padding : 13 px 14 px ; display : grid ; grid-template-columns : 40 px 1 fr auto ; gap : 11 px ; align-items : center ; border-bottom : 1 px solid var ( -- line ); }
. chain-row : last-child { border-bottom : 0 ; }
. chain-num { width : 32 px ; height : 32 px ; display : grid ; place-items : center ; border-radius : 50 % ; background : var ( -- primary - soft ); color : #cfc8ff ; font-weight : 800 ; }
. chain-row small { color : var ( -- muted ); }
2026-08-04 14:14:06 +08:00
. target-ref { display : inline-block ; margin-top : 3 px ; color : #b8e5ff ; font : 12 px / 1.4 ui-monospace , SFMono-Regular , Consolas , monospace ; }
. resolve-card { padding : 14 px ; }
. resolve-card h2 { margin : 0 0 4 px ; font-size : 15 px ; }
. resolve-card > p { margin : 0 0 12 px ; color : var ( -- muted ); }
. resolve-list { display : grid ; gap : 8 px ; }
. resolve-item { padding : 10 px 11 px ; border : 1 px solid var ( -- line ); border-radius : 9 px ; background : var ( -- surface -2 ); }
. resolve-item span , . resolve-item small { display : block ; color : var ( -- muted ); }
. resolve-item strong { display : block ; margin : 2 px 0 ; }
. roster-week { padding : 12 px ; display : grid ; grid-template-columns : repeat ( 7 , minmax ( 125 px , 1 fr )); gap : 8 px ; overflow-x : auto ; }
. roster-day { min-width : 125 px ; padding : 10 px ; border : 1 px solid var ( -- line ); border-radius : 9 px ; background : var ( -- surface -2 ); }
. roster-day . current { border-color : var ( -- primary ); background : var ( -- primary - soft ); }
. roster-day > strong { display : block ; margin-bottom : 8 px ; }
. shift-slot { padding : 8 px 0 ; border-top : 1 px solid var ( -- line ); }
. shift-slot : first-of-type { border-top : 0 ; }
. shift-slot span , . shift-slot small { display : block ; color : var ( -- muted ); font-size : 12 px ; }
. shift-slot strong { display : block ; margin : 2 px 0 ; font-size : 13 px ; }
. object-note { padding : 11 px 14 px ; border-top : 1 px solid var ( -- line ); color : var ( -- muted ); }
. contact-summary { grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr )); margin-bottom : 12 px ; }
. contact-summary . stat { min-height : 96 px ; }
2026-08-04 00:23:55 +08:00
. chart-grid { grid-template-columns : 1.35 fr .65 fr ; }
. chart { padding : 16 px ; }
. chart h2 { margin : 0 ; font-size : 15 px ; }
. chart p { margin : 4 px 0 14 px ; color : var ( -- muted ); }
. bars { height : 220 px ; display : flex ; align-items : end ; gap : 16 px ; padding : 10 px 6 px 26 px ; border-bottom : 1 px solid var ( -- line ); }
. bar { position : relative ; flex : 1 ; min-width : 24 px ; height : var ( -- h ); border-radius : 7 px 7 px 0 0 ; background : linear-gradient ( 180 deg , var ( -- primary ), #4f46a8 ); }
. bar span { position : absolute ; inset : calc ( 100 % + 5 px ) 0 auto ; text-align : center ; color : var ( -- muted ); font-size : 11 px ; }
. donut { width : 170 px ; height : 170 px ; margin : 18 px auto ; border-radius : 50 % ; background : conic-gradient ( var ( -- success ) 0 61 % , var ( -- warning ) 61 % 83 % , var ( -- danger ) 83 % ); position : relative ; }
. donut :: after { content : "83%\A 24h 内闭环" ; white-space : pre ; position : absolute ; inset : 27 px ; border-radius : 50 % ; display : grid ; place-items : center ; text-align : center ; background : var ( -- surface ); color : var ( -- text ); font-weight : 800 ; }
. legend { display : flex ; flex-wrap : wrap ; gap : 10 px ; color : var ( -- muted ); font-size : 12 px ; }
. legend i { display : inline-block ; width : 9 px ; height : 9 px ; margin-right : 5 px ; border-radius : 2 px ; }
2026-08-04 11:49:18 +08:00
. metric-note { margin : 12 px 0 0 ; padding : 10 px 12 px ; border-left : 3 px solid var ( -- cyan ); background : rgba ( 56 , 189 , 248 , .08 ); color : var ( -- muted ); }
2026-08-04 00:23:55 +08:00
. state-layer { display : none ; min-height : 420 px ; place-items : center ; padding : 24 px ; border : 1 px dashed var ( -- line ); border-radius : var ( -- radius ); background : var ( -- surface ); text-align : center ; }
. state-layer . show { display : grid ; }
. state-layer . icon { width : 40 px ; height : 40 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 ; max-width : 460 px ; color : var ( -- muted ); }
. skeleton { width : min ( 460 px , 90 % ); display : grid ; gap : 10 px ; }
. skeleton i { height : 15 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 : 72 % ; }
@ keyframes shimmer { to { background-position : -200 % 0 ; } }
dialog { width : min ( 610 px , calc ( 100 % - 28 px )); max-height : 88 dvh ; padding : 0 ; border : 1 px solid var ( -- line ); border-radius : 16 px ; background : var ( -- surface ); color : var ( -- text ); box-shadow : 0 24 px 80 px rgba ( 0 , 0 , 0 , .5 ); }
2026-08-04 14:14:06 +08:00
# ruleDialog , # eventDialog , # handoverDialog , # rollbackDialog , # escalationDialog , # scheduleDialog , # substituteDialog , # contactDialog { width : min ( 760 px , calc ( 100 % - 28 px )); }
2026-08-04 00:23:55 +08:00
dialog :: backdrop { background : rgba ( 4 , 7 , 15 , .68 ); backdrop-filter : blur ( 4 px ); }
. dialog-head , . dialog-foot { padding : 14 px 18 px ; border-bottom : 1 px solid var ( -- line ); display : flex ; align-items : center ; justify-content : space-between ; gap : 10 px ; }
. 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 ; }
. 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 ; }
2026-08-04 11:08:41 +08:00
. radio-card { display : block ; margin-bottom : 9 px ; padding : 11 px 12 px ; border : 1 px solid var ( -- line ); border-radius : 9 px ; background : var ( -- surface -2 ); }
2026-08-04 00:23:55 +08:00
. toast { position : fixed ; right : 20 px ; bottom : 20 px ; z-index : 1100 ; max-width : min ( 420 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 ; }
2026-08-04 11:49:18 +08:00
. mobile-manage { display : none ; }
2026-08-04 00:23:55 +08:00
@ media ( max-width : 1240px ) {
: root { --sidebar : 78 px ; }
. brand-copy , . nav span : not ( . count ), . nav-label , . shift-card { 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 ; }
. duty-grid { grid-template-columns : 285 px minmax ( 390 px , 1 fr ); }
. action-panel { grid-column : 1 /- 1 ; }
2026-08-04 09:07:23 +08:00
. action-scroll { max-height : none ; display : grid ; grid-template-columns : repeat ( 3 , 1 fr ); }
. disposition { border-top : 0 ; border-left : 1 px solid var ( -- line ); }
}
@ media ( max-width : 850px ) {
: root { --sidebar : 0 px ; }
. app { display : block ; padding-bottom : 66 px ; }
. sidebar { display : none ; }
. topbar { padding : 8 px 12 px ; }
. site span , . top-actions . select { display : none ; }
2026-08-04 11:49:18 +08:00
. mobile-manage { display : inline-flex ; min-width : 44 px ; }
2026-08-04 09:07:23 +08:00
. content { padding : 12 px ; }
2026-08-04 11:08:41 +08:00
. btn , . select , . field , . textarea { min-height : 44 px ; }
. icon-btn { min-width : 44 px ; }
2026-08-04 09:07:23 +08:00
. duty-grid , . chain-grid , . chart-grid { grid-template-columns : 1 fr ; }
. alert-list , . action-scroll { max-height : none ; }
. action-scroll { display : block ; }
. disposition { border-left : 0 ; border-top : 1 px solid var ( -- line ); }
. stats { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); }
2026-08-04 11:08:41 +08:00
. site-grid { grid-template-columns : 1 fr ; }
2026-08-04 14:14:06 +08:00
. roster-week { grid-template-columns : repeat ( 2 , minmax ( 145 px , 1 fr )); }
2026-08-04 09:07:23 +08:00
. rule-grid { grid-template-columns : 1 fr 1 fr ; }
. fact-grid { grid-template-columns : repeat ( 2 , 1 fr ); }
2026-08-04 11:49:18 +08:00
. pager { align-items : flex-start ; flex-direction : column ; }
2026-08-04 09:07:23 +08:00
. 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 ); }
. 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 : #c9c1ff ; background : var ( -- primary - soft ); }
}
@ media ( max-width : 560px ) {
. page-head { display : grid ; }
. stats , . rule-grid { grid-template-columns : 1 fr ; }
2026-08-04 14:14:06 +08:00
. chain-subnav { grid-template-columns : 1 fr ; }
. contact-summary { grid-template-columns : 1 fr ; }
. roster-week { grid-template-columns : 1 fr ; }
2026-08-04 09:07:23 +08:00
. form-grid { grid-template-columns : 1 fr ; }
. form-field . full { grid-column : auto ; }
. video-stage { margin-inline : 8 px ; }
. fact-grid { margin-inline : 8 px ; }
. evidence-summary { display : grid ; }
. disposition-actions { grid-template-columns : 1 fr ; }
}
@ 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-inbox" viewBox = "0 0 24 24" >< path d = "M4 4h16v16H4Z" />< path d = "m4 14 5-1 2 3h2l2-3 5 1" /></ symbol >
< symbol id = "i-event" viewBox = "0 0 24 24" >< path d = "M6 3h12v18H6z" />< path d = "M9 8h6m-6 4h6m-6 4h4" /></ symbol >
< symbol id = "i-rule" viewBox = "0 0 24 24" >< path d = "M4 6h7m6 0h3M4 12h3m6 0h7M4 18h9m6 0h1" />< circle cx = "14" cy = "6" r = "2" />< circle cx = "10" cy = "12" r = "2" />< circle cx = "16" cy = "18" r = "2" /></ symbol >
< symbol id = "i-phone" viewBox = "0 0 24 24" >< path d = "M5 4h4l2 5-3 2a16 16 0 0 0 5 5l2-3 5 2v4c-8 2-17-7-15-15Z" /></ symbol >
< symbol id = "i-chart" viewBox = "0 0 24 24" >< path d = "M4 20V9m6 11V4m6 16v-7m4 7H2" /></ symbol >
< symbol id = "i-shield" viewBox = "0 0 24 24" >< path d = "m12 3 8 3v6c0 5-3 8-8 9-5-1-8-4-8-9V6Z" />< path d = "m9 12 2 2 4-5" /></ 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-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-check" viewBox = "0 0 24 24" >< path d = "m5 12 4 4L19 6" /></ 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-play" viewBox = "0 0 24 24" >< path d = "m8 5 11 7-11 7Z" /></ symbol >
< symbol id = "i-expand" viewBox = "0 0 24 24" >< path d = "M8 3H3v5m13-5h5v5M8 21H3v-5m13 5h5v-5" /></ symbol >
< symbol id = "i-download" viewBox = "0 0 24 24" >< path d = "M12 3v12m0 0 5-5m-5 5-5-5M4 20h16" /></ symbol >
< symbol id = "i-lock" viewBox = "0 0 24 24" >< rect x = "4" y = "10" width = "16" height = "11" rx = "2" />< path d = "M8 10V7a4 4 0 0 1 8 0v3" /></ symbol >
< symbol id = "i-clock" viewBox = "0 0 24 24" >< circle cx = "12" cy = "12" r = "9" />< path d = "M12 7v6l4 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 >
</ svg >
< div class = "prototype-banner" > PROTOTYPE - 仅供枚举交互,非实现依据</ div >
< div class = "app" >
< aside class = "sidebar" aria-label = "Bell 主导航" >
< div class = "brand" >< div class = "brand-mark" > Y</ div >< div class = "brand-copy" >< strong > YoVision</ strong >< small > Bell · Duty Console</ small ></ div ></ div >
2026-08-04 11:49:18 +08:00
< div class = "nav-label" > 值班与业务</ div >
2026-08-04 09:07:23 +08:00
< nav class = "nav" >
< button data-view = "duty" aria-current = "page" >< svg class = "icon" >< use href = "#i-inbox" /></ svg >< span > 值班台</ span >< span class = "count" > 6</ span ></ button >
< button data-view = "events" >< svg class = "icon" >< use href = "#i-event" /></ svg >< span > 事件中心</ span ></ button >
< button data-view = "rules" >< svg class = "icon" >< use href = "#i-rule" /></ svg >< span > 规则策略</ span ></ button >
< button data-view = "chains" >< svg class = "icon" >< use href = "#i-phone" /></ svg >< span > 升级链</ span ></ button >
< button data-view = "reports" >< svg class = "icon" >< use href = "#i-chart" /></ svg >< span > 运营报表</ span ></ button >
2026-08-04 11:49:18 +08:00
</ nav >
< div class = "nav-label" > 管理</ div >
< nav class = "nav" >
2026-08-04 11:08:41 +08:00
< button data-view = "sites" >< svg class = "icon" >< use href = "#i-rule" /></ svg >< span > 站点与 Area</ span ></ button >
2026-08-04 09:07:23 +08:00
< button data-view = "audit" >< svg class = "icon" >< use href = "#i-shield" /></ svg >< span > 审计日志</ span ></ button >
</ nav >
< div class = "shift-card" >< strong > 安保值班组 · 白班</ strong >< span > 08:00– 20:00 · 4 人在线</ span >< div class = "shift-bar" >< i ></ i ></ div ></ div >
</ aside >
< main class = "main" id = "main-content" tabindex = "-1" >
< header class = "topbar" >
2026-08-04 11:08:41 +08:00
< div class = "site" >< strong > 青藤教育集团 · 青藤寄宿学校</ strong >< span > 当前账号:林工 · 租户管理员 · Asia/Shanghai</ span ></ div >
2026-08-04 09:07:23 +08:00
< div class = "top-actions" >
2026-08-04 11:49:18 +08:00
< select class = "select" id = "stateSelect" aria-label = "原型状态演示" >< option value = "normal" > 正常状态</ option >< option value = "loading" > 加载中</ option >< option value = "weak" > 弱网 / 证据失败</ option >< option value = "empty" > 队列为空</ option >< option value = "denied" > 无权限</ option >< option value = "expired" > 会话已过期</ option ></ select >
< button class = "btn mobile-manage" id = "mobileManage" aria-haspopup = "dialog" > 管理</ button >
2026-08-04 09:07:23 +08:00
< 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 active" data-page = "duty" >
2026-08-04 11:49:18 +08:00
< div class = "page-head" >< div >< h1 > 实时值班台</ h1 >< p > 先确认高危预警,再查看证据与升级状态;重复投递不会生成重复处置。</ p ></ div >< div class = "page-actions" >< span class = "badge success" > 值班组在线</ span >< button class = "btn" id = "handoverButton" > 发起交接班</ button >< button class = "btn" id = "muteButton" > 限时静默</ button ></ div ></ div >
2026-08-04 09:07:23 +08:00
< div class = "duty-grid" >
< section class = "card queue" aria-label = "待处置预警队列" >
< div class = "card-head" >< h2 > 待处置预警</ h2 >< span class = "badge danger" > 6 个未 ack</ span ></ div >
2026-08-04 11:08:41 +08:00
< div class = "queue-head" >< div class = "queue-tabs" aria-label = "预警队列筛选" >< button class = "active" aria-pressed = "true" > 全部 9</ button >< button aria-pressed = "false" > 高危 3</ button >< button aria-pressed = "false" > 我的 2</ button ></ div ></ div >
2026-08-04 09:07:23 +08:00
< div class = "alert-list" id = "alertList" ></ div >
</ section >
< section class = "card evidence" aria-label = "事件事实与证据" >
< div class = "evidence-summary" >< div >< span class = "badge danger" id = "selectedSeverity" > 高危 · S1</ span >< h2 id = "selectedTitle" > 危险区域闯入</ h2 >< p id = "selectedMeta" > 北围墙 01 · 23:14:08 · EVT-01J8…7A2</ p ></ div >< button class = "btn icon-btn" aria-label = "更多事件操作" >< svg class = "icon" >< use href = "#i-more" /></ svg ></ button ></ div >
2026-08-04 11:08:41 +08:00
< div class = "evidence-tabs" aria-label = "证据类型筛选" >< button class = "active" aria-pressed = "true" > 视频片段</ button >< button aria-pressed = "false" > 抓拍 2</ button >< button aria-pressed = "false" > 结构化事实</ button ></ div >
2026-08-04 09:07:23 +08:00
< div class = "video-stage" id = "videoStage" >
< svg viewBox = "0 0 960 540" role = "img" aria-label = "北围墙 01 事件视频模拟画面,匿名目标进入危险区域" >
< defs >< linearGradient id = "bellSky" x1 = "0" y1 = "0" x2 = "0" y2 = "1" >< stop stop-color = "#102337" />< stop offset = "1" stop-color = "#060a11" /></ linearGradient >< pattern id = "bellScan" width = "8" height = "8" patternUnits = "userSpaceOnUse" >< path d = "M0 8h8" stroke = "#fff" stroke-opacity = ".035" /></ pattern ></ defs >
< rect width = "960" height = "540" fill = "url(#bellSky)" />< path d = "M0 340 180 260 355 316 520 232 690 306 830 230 960 285V540H0Z" fill = "#142638" />< rect y = "414" width = "960" height = "126" fill = "#111820" />< path d = "M0 416h960M0 474h960" stroke = "#6d8496" stroke-opacity = ".48" />< path d = "M150 305v235m250-240v240m280-255v255m170-280v280" stroke = "#758b9d" stroke-width = "4" />< polygon points = "92,362 305,318 558,342 525,480 126,478" fill = "#fb7185" fill-opacity = ".15" stroke = "#fb7185" stroke-width = "4" stroke-dasharray = "10 7" />< rect x = "354" y = "330" width = "63" height = "148" fill = "none" stroke = "#fbbf24" stroke-width = "4" />< text x = "354" y = "319" fill = "#ffe18a" font-size = "17" > person · 0.92</ text >< circle cx = "385" cy = "310" r = "24" fill = "none" stroke = "#fbbf24" stroke-width = "4" />< rect width = "960" height = "540" fill = "url(#bellScan)" />< text x = "26" y = "38" fill = "#d6e8f5" font-size = "18" > 北围墙 01 · 事件片段</ text >< text x = "26" y = "66" fill = "#91a6b8" font-size = "14" > 事发前 8s · 事发后 12s · 匿名目标</ text >
</ svg >
< div class = "video-controls" >< button aria-label = "播放片段" >< svg class = "icon" >< use href = "#i-play" /></ svg ></ button >< span class = "mono" > 00:11 / 00:20</ span >< div class = "timeline-bar" >< i ></ i ></ div >< button aria-label = "全屏查看" >< svg class = "icon" >< use href = "#i-expand" /></ svg ></ button >< button aria-label = "下载授权证据" >< svg class = "icon" >< use href = "#i-download" /></ svg ></ button ></ div >
</ div >
< div class = "fact-grid" >
< div class = "fact" >< span > 规则</ span >< strong id = "factRule" > 非授权人员进入</ strong ></ div >< div class = "fact" >< span > 置信度</ span >< strong > 0.92</ strong ></ div >< div class = "fact" >< span > 持续时间</ span >< strong > 8.4s</ strong ></ div >< div class = "fact" >< span > 证据状态</ span >< strong style = "color:var(--success)" > 已校验</ strong ></ div >
</ div >
2026-08-04 11:49:18 +08:00
< div class = "relation-panel" >< h3 > 关联事件 · < span id = "relatedEventCount" > 2</ span ></ h3 >< div class = "relation-list" id = "relatedEvents" ></ div ></ div >
2026-08-04 09:07:23 +08:00
< div class = "privacy-note" >< svg class = "icon" >< use href = "#i-lock" /></ svg >< span > 仅显示与本事件关联的 20 秒证据。常态录像仍在客户 NVR,本页不暴露流地址或摄像头凭据。</ span ></ div >
</ section >
< aside class = "card action-panel" aria-label = "预警确认与处置" >
< div class = "card-head" >< h2 > 处置与升级</ h2 >< span class = "badge warning" id = "ackState" > 等待 ack</ span ></ div >
< div class = "action-scroll" >
2026-08-04 11:49:18 +08:00
< div class = "ack-box" >< button class = "btn btn-primary" id = "ackButton" >< svg class = "icon" >< use href = "#i-check" /></ svg > 确认接手</ button >< p id = "ackHelp" > 一次点击确认;并发确认时会显示实际接手人,不覆盖对方结果。</ p >< div class = "ack-tools" >< button class = "text-button" id = "simulateAckConflict" > 演示另一值班员抢先 ack</ button ></ div >< div class = "inline-alert" id = "ackConflict" role = "alert" hidden ></ div ></ div >
2026-08-04 09:07:23 +08:00
< div >< div class = "countdown" >< svg class = "icon" style = "color:var(--warning);width:26px;height:26px" >< use href = "#i-clock" /></ svg >< div >< strong id = "countdown" > 01:42</ strong >< span > 未 ack 将升级到校级负责人</ span ></ div ></ div >
< div class = "escalation" >< h3 > 投递时间线</ h3 >
2026-08-04 11:49:18 +08:00
< div class = "step done" >< i class = "step-dot" ></ i >< strong > 值班室 Web + 本地声光</ strong >< div class = "delivery-facts" >< span > 已发出 23:14:10</ span >< span > 已送达 23:14:11</ span >< span > 已看到 23:14:31</ span ></ div ></ div >
< div class = "step current" >< i class = "step-dot" ></ i >< strong > 值班员短信 · 可重试</ strong >< div class = "delivery-facts" >< span > 已发出 23:14:13</ span >< span style = "color:var(--warning)" > 未收到送达回执 · 已等待 42 秒</ span >< span > 通道不支持“已看到” · 重试 2/3,23:15:25</ span ></ div ></ div >
< div class = "step" >< i class = "step-dot" ></ i >< strong > 校级负责人语音</ strong >< div class = "delivery-facts" >< span > 预计 23:16:10 · 尚未发送</ span >< span > 号码无效将标记最终失败,不继续伪装送达</ span ></ div ></ div >
2026-08-04 09:07:23 +08:00
</ div >
</ div >
< div class = "disposition" >< label for = "outcome" > 处置结果</ label >< select class = "select" id = "outcome" >< option value = "" > 待选择</ option >< option > 已现场核查,无人员受伤</ option >< option > 已通知安保人员处理</ option >< option > 已转交校方负责人</ option ></ select >< label for = "note" > 处置备注</ label >< textarea class = "textarea" id = "note" placeholder = "记录已采取的动作,不改写原始事件事实" ></ textarea >< div class = "disposition-actions" >< button class = "btn btn-danger" id = "falsePositive" > 标记误报</ button >< button class = "btn btn-success" id = "completeAlert" > 完成处置</ button ></ div ></ div >
</ div >
</ aside >
</ div >
</ section >
< section class = "view" data-page = "events" >
< div class = "page-head" >< div >< h1 > 事件中心</ h1 >< p > 事件是不可变事实;预警与处置结果作为关联记录单独展示。</ p ></ div >< div class = "page-actions" >< button class = "btn" >< svg class = "icon" >< use href = "#i-download" /></ svg > 导出筛选结果</ button ></ div ></ div >
< div class = "grid stats" >
< article class = "card stat" >< span > 今日事件</ span >< strong > 47</ strong >< span class = "muted" > 较昨日 -8%</ span ></ article >< article class = "card stat" >< span > 已触发预警</ span >< strong > 18</ strong >< span class = "muted" > 规则命中后独立创建</ span ></ article >< article class = "card stat" >< span > 误报反馈</ span >< strong > 5</ strong >< span class = "muted" > 进入标注队列</ span ></ article >< article class = "card stat" >< span > 证据可用率</ span >< strong > 97.9%</ strong >< span class = "muted" > 1 个片段可重试</ span ></ article >
</ div >
2026-08-04 11:49:18 +08:00
< 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 > S1 高危</ option >< option > S2 重要</ option ></ select >< select class = "select" aria-label = "结果筛选" >< option > 全部结果</ option >< option > 已处置</ option >< option > 误报</ option ></ select ></ div >< span class = "muted" > 共 47 条</ span ></ div >< div class = "table-wrap" >< table >< thead >< tr >< th > 事件编号</ th >< th > 时间</ th >< th > 点位</ th >< th > 规则</ th >< th > 事实</ th >< th > 预警</ th >< th > 结果</ th >< th > 证据</ th ></ tr ></ thead >< tbody id = "eventRows" ></ tbody ></ table ></ div >< div class = "pager" >< span id = "eventPageSummary" > 第 1 / 16 页 · 每页 3 条</ span >< div class = "pager-actions" >< button class = "btn" id = "eventPrev" > 上一页</ button >< button class = "btn" id = "eventNext" > 下一页</ button ></ div ></ div ></ article >
2026-08-04 09:07:23 +08:00
</ section >
< section class = "view" data-page = "rules" >
2026-08-04 10:05:56 +08:00
< div class = "rule-context" id = "ruleContext" hidden >< p >< strong > 已带入摄像头与区域上下文:</ strong >< span id = "ruleContextCamera" > 北围墙 01</ span > · < span id = "ruleContextZone" > ZONE-007</ span > ,可继续完成规则草稿。</ p >< button class = "btn btn-primary" id = "continueRuleDraft" > 继续配置</ button ></ div >
2026-08-04 09:07:23 +08:00
< div class = "page-head" >< div >< h1 > 规则策略</ h1 >< p > Bell 管理场景、范围、时段、持续时间、试运行与发布;检测区域来自对应摄像头的版本化空间配置。</ p ></ div >< div class = "page-actions" >< button class = "btn btn-primary" id = "openRuleDialog" > 新建规则</ button ></ div ></ div >
< div class = "grid rule-grid" >
2026-08-04 11:49:18 +08:00
< article class = "card rule-card" >< div class = "rule-card-top" >< span class = "badge success" id = "publishedRuleBadge" > 正式生效 · v7</ span >< button class = "btn btn-quiet icon-btn" aria-label = "更多操作" >< svg class = "icon" >< use href = "#i-more" /></ svg ></ button ></ div >< h2 > 非授权人员进入</ h2 >< p > 危险区域 × 全天 × 持续 3 秒</ p >< dl >< dt > 覆盖范围</ dt >< dd > 4 个点位</ dd >< dt > 检测区域</ dt >< dd > 北围墙危险区域 v7</ dd >< dt > 继承来源</ dt >< dd > 站点模板</ dd >< dt > 今日命中</ dt >< dd > 8</ dd ></ dl >< div class = "rule-card-actions" >< button class = "btn" data-edit-rule > 编辑规则</ button >< button class = "btn" id = "openRollback" > 版本历史 / 回滚</ button >< a class = "btn" href = "../sense/index.html?view=device-detail&camera=CAM-S2-001&zone=ZONE-007&return_to=bell-rule" > 校准检测区域</ a ></ div ></ article >
2026-08-04 09:07:23 +08:00
< article class = "card rule-card" >< div class = "rule-card-top" >< span class = "badge warning" id = "trialBadge" > 试运行</ span >< button class = "btn btn-quiet icon-btn" aria-label = "更多操作" >< svg class = "icon" >< use href = "#i-more" /></ svg ></ button ></ div >< h2 > 区域内聚集</ h2 >< p > 人数 ≥ 6 × 持续 20 秒 × 夜间</ p >< dl >< dt > 覆盖范围</ dt >< dd > 3 个点位</ dd >< dt > 检测区域</ dt >< dd > 3 个区域版本</ dd >< dt > 继承来源</ dt >< dd > 设备覆盖</ dd >< dt > 样本命中</ dt >< dd > 14</ dd ></ dl >< div class = "rule-card-actions" >< button class = "btn" data-edit-rule > 查看试运行</ button >< button class = "btn btn-primary" id = "publishTrialRule" > 正式发布</ button ></ div ></ article >
2026-08-04 10:05:56 +08:00
< article class = "card rule-card" >< div class = "rule-card-top" >< span class = "badge violet" > 草稿</ span >< button class = "btn btn-quiet icon-btn" aria-label = "更多操作" >< svg class = "icon" >< use href = "#i-more" /></ svg ></ button ></ div >< h2 > 方向越线</ h2 >< p > 警戒线 A → B × 上学时段</ p >< dl >< dt > 覆盖范围</ dt >< dd > 1 个点位</ dd >< dt > 检测区域</ dt >< dd > 宿舍东通道 · 警戒线 v3</ dd >< dt > 继承来源</ dt >< dd > 设备草稿</ dd >< dt > 最近修改</ dt >< dd > 2h 前</ dd ></ dl >< div class = "rule-card-actions" >< button class = "btn" data-edit-rule > 继续编辑</ button >< a class = "btn" href = "../sense/index.html?view=device-detail&camera=CAM-S2-011&zone=ZONE-011&return_to=bell-rule" > 查看警戒线</ a ></ div ></ article >
2026-08-04 00:23:55 +08:00
</ div >
</ section >
< section class = "view" data-page = "chains" >
2026-08-04 14:14:06 +08:00
< div class = "page-head" >< div >< h1 > 升级链</ h1 >< p > 联系人、值班排班和升级策略统一设计、分对象管理;策略引用动态目标,不写死手机号。</ p ></ div >< div class = "page-actions" >< span class = "badge success" > 当前排班覆盖完整</ span ></ div ></ div >
< div class = "chain-subnav" aria-label = "升级链二级模块" >< button class = "active" data-chain-section = "strategy" aria-pressed = "true" > 升级策略</ button >< button data-chain-section = "roster" aria-pressed = "false" > 值班与排班</ button >< button data-chain-section = "contacts" aria-pressed = "false" > 联系人和通道</ button ></ div >
< section class = "chain-panel active" data-chain-panel = "strategy" >
< div class = "grid chain-grid" >< article class = "card" >< div class = "card-head" >< div >< h2 > 校园高危事件 · 默认链</ h2 >< span class = "muted" > 每一步引用类型化目标,解析后才创建投递</ span ></ div >< span class = "badge success" > 已发布 v4</ span ></ div >
< div class = "chain-row" >< div class = "chain-num" > 1</ div >< div >< strong > 值班室 Web + 本地声光</ strong >< small > 立即 · 站点局域网内</ small >< span class = "target-ref" > team:DUTY-ROOM</ span ></ div >< span class = "badge success" > 固定值班组</ span ></ div >
< div class = "chain-row" >< div class = "chain-num" > 2</ div >< div >< strong > 当前排班值班员 · 短信</ strong >< small > 立即 · 送达后等待 2 分钟</ small >< span class = "target-ref" > on_call_schedule:SCH-CAMPUS-SECURITY@v12</ span ></ div >< span class = "badge info" > 动态排班</ span ></ div >
< div class = "chain-row" >< div class = "chain-num" > 3</ div >< div >< strong > 校级负责人 · 语音</ strong >< small > 未 ack 时升级 · 等待 3 分钟</ small >< span class = "target-ref" > person:USR-SCHOOL-LEAD</ span ></ div >< span class = "badge warning" > 指定人员</ span ></ div >
< div class = "object-note" > 升级策略只保存目标引用和通道偏好,不复制联系人号码或轮换字段。</ div >
</ article >< article class = "card resolve-card" >< div class = "card-head" style = "padding:0 0 11px;margin-bottom:12px" >< div >< h2 > 目标解析预览</ h2 >< span class = "muted" > 事件时间 2026-08-04 23:14 · Asia/Shanghai</ span ></ div >< button class = "btn" id = "editEscalation" > 编辑策略</ button ></ div >< div class = "resolve-list" >< div class = "resolve-item" >< span > 排班目标</ span >< strong > 校园安保值班表 · v12</ strong >< small class = "mono" > SCH-CAMPUS-SECURITY</ small ></ div >< div class = "resolve-item" >< span > 当前解析结果</ span >< strong id = "resolvedPerson" > 王值班员 · 晚班</ strong >< small > 短信已验证 · 语音已验证</ small ></ div >< div class = "resolve-item" >< span > 投递快照</ span >< strong > 23:14:13 已固化</ strong >< small > 收件人、通道、排班 v12;后续编辑不改写本次事实</ small ></ div ></ div ></ article ></ div >
< div class = "grid chain-grid" style = "margin-top:12px" >< article class = "card" >< div class = "card-head" >< h2 > 解析与版本规则</ h2 >< span class = "badge info" > 不可变历史</ span ></ div >< div class = "card-body" >< p class = "muted" > 每次投递创建时按站点时区解析当时生效的排班版本,并保存实际收件人、通道与版本快照。未来排班版本只影响其生效时间后的新投递。</ p ></ div ></ article >< article class = "card" >< div class = "card-head" >< h2 > 静默规则</ h2 >< span class = "badge info" > 无永久选项</ span ></ div >< div class = "card-body" >< label for = "muteDuration" class = "muted" > 维护窗口</ label >< select id = "muteDuration" class = "select" style = "margin-top:6px" >< option > 30 分钟</ option >< option > 1 小时</ option >< option > 2 小时</ option >< option > 4 小时(上限)</ option ></ select >< p class = "muted" > 静默仅影响选定规则和点位,到时自动恢复;S1 事件不可静默。</ p >< button class = "btn" > 创建维护窗口</ button ></ div ></ article ></ div >
</ section >
< section class = "chain-panel" data-chain-panel = "roster" >
< div class = "grid stats" >< article class = "card stat" >< span > 当前排班</ span >< strong id = "scheduleVersion" > v12</ strong >< span class = "muted" > 已发布 · 08-01 生效</ span ></ article >< article class = "card stat" >< span > 站点时区</ span >< strong style = "font-size:18px" > Asia/Shanghai</ strong >< span class = "muted" > 跨午夜班次按站点时间</ span ></ article >< article class = "card stat" >< span > 当前值班</ span >< strong style = "font-size:18px" > 王值班员</ strong >< span class = "muted" > 晚班 · 至明日 08:00</ span ></ article >< article class = "card stat" >< span > 未来 14 天覆盖</ span >< strong > 100%</ strong >< span class = "muted" > 0 空档 · 0 重叠</ span ></ article ></ div >
< article class = "card" >< div class = "card-head" >< div >< h2 > 校园安保值班表</ h2 >< span class = "muted" > 周轮换 · 白班 08:00– 20:00 / 晚班 20:00– 08:00</ span ></ div >< div class = "page-actions" >< button class = "btn" id = "simulateRosterConflict" > 演示冲突检查</ button >< button class = "btn" id = "openSubstitute" > 临时替班</ button >< button class = "btn btn-primary" id = "openSchedulePublish" > 发布新版本</ button ></ div ></ div >< div class = "inline-alert danger" id = "rosterConflict" role = "alert" hidden style = "margin:12px 12px 0" ></ div >< div class = "roster-week" >
< div class = "roster-day current" >< strong > 今天 · 周二</ strong >< div class = "shift-slot" >< span > 白班</ span >< strong > 林值班员</ strong >< small > 08:00– 20:00</ small ></ div >< div class = "shift-slot" >< span > 晚班</ span >< strong id = "currentNightShift" > 王值班员</ strong >< small > 20:00– 08:00</ small ></ div ></ div >
< div class = "roster-day" >< strong > 周三</ strong >< div class = "shift-slot" >< span > 白班</ span >< strong > 赵值班员</ strong >< small > 08:00– 20:00</ small ></ div >< div class = "shift-slot" >< span > 晚班</ span >< strong id = "wednesdayNightShift" > 周值班员</ strong >< small > 20:00– 08:00</ small ></ div ></ div >
< div class = "roster-day" >< strong > 周四</ strong >< div class = "shift-slot" >< span > 白班</ span >< strong > 林值班员</ strong >< small > 08:00– 20:00</ small ></ div >< div class = "shift-slot" >< span > 晚班</ span >< strong > 王值班员</ strong >< small > 20:00– 08:00</ small ></ div ></ div >
< div class = "roster-day" >< strong > 周五</ strong >< div class = "shift-slot" >< span > 白班</ span >< strong > 赵值班员</ strong >< small > 08:00– 20:00</ small ></ div >< div class = "shift-slot" >< span > 晚班</ span >< strong > 周值班员</ strong >< small > 20:00– 08:00</ small ></ div ></ div >
< div class = "roster-day" >< strong > 周六</ strong >< div class = "shift-slot" >< span > 白班</ span >< strong > 林值班员</ strong >< small > 08:00– 20:00</ small ></ div >< div class = "shift-slot" >< span > 晚班</ span >< strong > 王值班员</ strong >< small > 20:00– 08:00</ small ></ div ></ div >
< div class = "roster-day" >< strong > 周日</ strong >< div class = "shift-slot" >< span > 白班</ span >< strong > 赵值班员</ strong >< small > 08:00– 20:00</ small ></ div >< div class = "shift-slot" >< span > 晚班</ span >< strong > 周值班员</ strong >< small > 20:00– 08:00</ small ></ div ></ div >
< div class = "roster-day" >< strong > 下周一</ strong >< div class = "shift-slot" >< span > 白班</ span >< strong > 林值班员</ strong >< small > 08:00– 20:00</ small ></ div >< div class = "shift-slot" >< span > 晚班</ span >< strong > 王值班员</ strong >< small > 20:00– 08:00</ small ></ div ></ div >
</ div >< div class = "object-note" >< strong > 边界:</ strong > 临时替班进入新排班草稿并经发布后影响未来班次;值班交接只转移进行中 Alert 的责任,不会暗改这里的轮换。</ div ></ article >
< article class = "card" style = "margin-top:12px" >< 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 > v12</ td >< td > 2026-08-01 00:00</ td >< td > 暑期周轮换</ td >< td >< span class = "badge success" > 当前生效</ span ></ td >< td > 林工</ td ></ tr >< tr >< td > v11</ td >< td > 2026-07-01 00:00</ td >< td > 赵值班员加入白班组</ td >< td >< span class = "badge" > 历史</ span ></ td >< td > 周管理员</ td ></ tr ></ tbody ></ table ></ div ></ article >
</ section >
< section class = "chain-panel" data-chain-panel = "contacts" >
< div class = "grid contact-summary" >< article class = "card stat" >< span > 联系人</ span >< strong id = "contactCount" > 6</ strong >< span class = "muted" > 4 名值班员 · 2 名负责人</ span ></ article >< article class = "card stat" >< span > 已验证通道</ span >< strong > 11 / 12</ strong >< span class = "muted" > 1 个语音号码待验证</ span ></ article >< article class = "card stat" >< span > 被排班引用</ span >< strong > 4</ strong >< span class = "muted" > 删除前必须解除引用</ span ></ article ></ div >
< article class = "card" >< div class = "card-head" >< div >< h2 > 联系人和通知通道</ h2 >< span class = "muted" > 只维护身份、角色、值班组和通道;班次与轮换在排班模块维护</ span ></ div >< button class = "btn btn-primary" id = "openContact" > 新建联系人</ button ></ div >< div class = "table-wrap" >< table >< thead >< tr >< th > 联系人</ th >< th > 角色</ th >< th > 值班组</ th >< th > 通知通道</ th >< th > 排班引用</ th >< th > 状态</ th >< th > 操作</ th ></ tr ></ thead >< tbody >< tr >< td >< strong > 王值班员</ strong >< br >< span class = "mono muted" > USR-DUTY-WANG</ span ></ td >< td > 值班员</ td >< td > 校园安保组</ td >< td > 短信 ✓ · 语音 ✓ · Web ✓</ td >< td > SCH-CAMPUS-SECURITY</ td >< td >< span class = "badge success" > 可用</ span ></ td >< td >< button class = "btn" data-edit-contact = "wang" > 编辑</ button ></ td ></ tr >< tr >< td >< strong > 林值班员</ strong >< br >< span class = "mono muted" > USR-DUTY-LIN</ span ></ td >< td > 值班员</ td >< td > 校园安保组</ td >< td > 短信 ✓ · Web ✓</ td >< td > SCH-CAMPUS-SECURITY</ td >< td >< span class = "badge success" > 可用</ span ></ td >< td >< button class = "btn" data-edit-contact = "lin" > 编辑</ button ></ td ></ tr >< tr >< td >< strong > 校级负责人</ strong >< br >< span class = "mono muted" > USR-SCHOOL-LEAD</ span ></ td >< td > 站点负责人</ td >< td > 校级负责人组</ td >< td > 短信 ✓ · 语音待验证</ td >< td > 升级策略 v4</ td >< td >< span class = "badge warning" > 部分可用</ span ></ td >< td >< button class = "btn" data-edit-contact = "lead" > 编辑</ button ></ td ></ tr ></ tbody ></ table ></ div >< div class = "object-note" > 联系人通道变更只影响后续新投递;已创建投递保留当时的收件人和通道快照。联系人被排班或策略引用时不能直接删除。</ div ></ article >
</ section >
2026-08-04 00:23:55 +08:00
</ section >
< section class = "view" data-page = "reports" >
< div class = "page-head" >< div >< h1 > 运营报表</ h1 >< p > 算法效果和系统 SLA 分开统计;图表同时提供表格导出。</ p ></ div >< div class = "page-actions" >< button class = "btn" >< svg class = "icon" >< use href = "#i-download" /></ svg > 导出 CSV</ button ></ div ></ div >
< div class = "grid chart-grid" >< article class = "card chart" >< h2 > 近 7 日预警量</ h2 >< p > 按站点日期汇总;可切换到每路每天误报数。</ p >< div class = "bars" role = "img" aria-label = "近七日预警量依次为 18、22、15、31、27、24、19" >< div class = "bar" style = "--h:58%" >< span > 周一</ span ></ div >< div class = "bar" style = "--h:70%" >< span > 周二</ span ></ div >< div class = "bar" style = "--h:48%" >< span > 周三</ span ></ div >< div class = "bar" style = "--h:100%" >< span > 周四</ span ></ div >< div class = "bar" style = "--h:87%" >< span > 周五</ span ></ div >< div class = "bar" style = "--h:77%" >< span > 周六</ span ></ div >< div class = "bar" style = "--h:61%" >< span > 周日</ span ></ div ></ div ></ article >< article class = "card chart" >< h2 > 处置结果</ h2 >< p > 本月已闭环事件。</ p >< div class = "donut" role = "img" aria-label = "正常处置 61%,误报 22%,升级未闭环 17%" ></ div >< div class = "legend" >< span >< i style = "background:var(--success)" ></ i > 正常处置 61%</ span >< span >< i style = "background:var(--warning)" ></ i > 误报 22%</ span >< span >< i style = "background:var(--danger)" ></ i > 未闭环 17%</ span ></ div ></ article ></ div >
2026-08-04 11:49:18 +08:00
< article class = "card" style = "margin-top:12px" >< div class = "card-head" >< div >< h2 > 规则验收效果</ h2 >< span class = "muted" > 冻结样本窗口:2026-07-01~ 2026-07-31 · 现场复核集 v3</ span ></ div >< button class = "btn" > 导出验收附件</ button ></ div >< div class = "table-wrap" >< table >< thead >< tr >< th > 规则版本</ th >< th > 样本窗口</ th >< th > 正样本 / 漏检</ th >< th > 召回率</ th >< th > 通道天数</ th >< th > 误报数</ th >< th > 每路每天误报数</ th ></ tr ></ thead >< tbody >< tr >< td > 非授权人员进入 · v7</ td >< td > 07-01~ 07-31</ td >< td > 186 / 8</ td >< td >< strong > 95.7%</ strong ></ td >< td > 496</ td >< td > 19</ td >< td >< strong > 0.038</ strong ></ td ></ tr >< tr >< td > 区域内聚集 · v4 试运行</ td >< td > 07-15~ 07-31</ td >< td > 72 / 6</ td >< td >< strong > 91.7%</ strong ></ td >< td > 272</ td >< td > 16</ td >< td >< strong > 0.059</ strong ></ td ></ tr ></ tbody ></ table ></ div >< p class = "metric-note" >< strong > 口径说明:</ strong > 按规则版本分别报告召回率与每路每天误报数;不提供跨场景统一“准确率”。样本量和冻结窗口随导出保留。</ p ></ article >
2026-08-04 00:23:55 +08:00
</ section >
2026-08-04 11:08:41 +08:00
< section class = "view" data-page = "sites" >
< div class = "page-head" >< div >< h1 > 站点与 Area</ h1 >< p > Bell 是 Tenant、Site、Area、配额与隐私准入策略的唯一真相源;Sense 只消费版本化投影。</ p ></ div >< div class = "page-actions" >< button class = "btn" > 管理角色权限</ button >< button class = "btn btn-primary" id = "newSite" > 新建站点</ button ></ div ></ div >
< div class = "grid stats" >< article class = "card stat" >< span > 租户</ span >< strong > 1</ strong >< span class = "muted" > 青藤教育集团</ span ></ article >< article class = "card stat" >< span > 站点</ span >< strong > 2</ strong >< span class = "muted" > 1 个试点中</ span ></ article >< article class = "card stat" >< span > 视频配额</ span >< strong > 16 / 128</ strong >< span class = "muted" > 默认 16,不是硬上限</ span ></ article >< article class = "card stat" >< span > 策略投影</ span >< strong > v42</ strong >< span class = "muted" > Sense 2 分钟前已同步</ span ></ article ></ div >
< div class = "grid site-grid" >< article class = "card" >< div class = "card-head" >< h2 > 站点</ h2 >< button class = "btn btn-quiet" > 筛选</ button ></ div >< div class = "site-list" >< button class = "site-item active" >< span >< strong > 青藤寄宿学校</ strong >< small > site-ivy · S2 校园包</ small ></ span >< span class = "badge success" > 启用</ span ></ button >< button class = "site-item" >< span >< strong > 城东试验点</ strong >< small > site-east · 未绑定场景包</ small ></ span >< span class = "badge warning" > 准备中</ span ></ button ></ div ></ article >
< article class = "card" >< div class = "card-head" >< div >< h2 > 青藤寄宿学校 · Area</ h2 >< span class = "muted" > 层级、报表口径和设备准入共用同一逻辑 ID</ span ></ div >< button class = "btn btn-primary" id = "newArea" > 新建 Area</ button ></ div >< div class = "policy-note" >< strong > 策略变更不会静默处置已有设备。</ strong > 若改为“仅非成像”且 Area 内已有摄像头,必须显式迁移设备或取消变更。</ div >< div class = "table-wrap" >< table >< thead >< tr >< th > Area</ th >< th > 父级</ th >< th > capture_policy</ th >< th > 设备</ th >< th > 策略版本</ th >< th > 投影状态</ th >< th > 操作</ th ></ tr ></ thead >< tbody >< tr >< td >< strong > 北围墙</ strong >< br >< span class = "muted" > area-north-wall</ span ></ td >< td > 校园</ td >< td >< span class = "badge success" > 允许成像</ span ></ td >< td > 4 视频</ td >< td > v42</ td >< td >< span class = "badge success" > 已同步</ span ></ td >< td >< button class = "btn" data-edit-area = "north" > 编辑策略</ button ></ td ></ tr >< tr >< td >< strong > 宿舍卧室</ strong >< br >< span class = "muted" > area-dorm-room</ span ></ td >< td > 宿舍楼</ td >< td >< span class = "badge warning" > 仅非成像</ span ></ td >< td > 0 视频 · 1 雷达规划</ td >< td > v38</ td >< td >< span class = "badge success" > 已同步</ span ></ td >< td >< button class = "btn" data-edit-area = "dorm" > 编辑策略</ button ></ td ></ tr >< tr >< td >< strong > 实验楼入口</ strong >< br >< span class = "muted" > area-lab-gate</ span ></ td >< td > 实验楼</ td >< td >< span class = "badge success" > 允许成像</ span ></ td >< td > 2 视频</ td >< td > v41</ td >< td >< span class = "badge warning" > 待 Sense 拉取</ span ></ td >< td >< button class = "btn" data-edit-area = "lab" > 编辑策略</ button ></ td ></ tr ></ tbody ></ table ></ div ></ article ></ div >
</ section >
2026-08-04 00:23:55 +08:00
< section class = "view" data-page = "audit" >
2026-08-04 11:08:41 +08:00
< div class = "context-strip" id = "auditContext" hidden >< p >< strong > 来自 Sense 设备日志:</ strong > 租户 < span id = "auditTenant" > —</ span > · 站点 < span id = "auditSite" > —</ span > · 设备 < span id = "auditDevice" > —</ span > 。已锁定筛选上下文。</ p >< a class = "btn" id = "auditReturn" href = "../sense/index.html?view=device-detail" > 返回设备详情 ↗</ a ></ div >
2026-08-04 00:23:55 +08:00
< div class = "page-head" >< div >< h1 > 审计日志</ h1 >< p > 查询、ack、规则发布和证据下载均可追溯;不记录密码与可复用连接串。</ p ></ div >< div class = "page-actions" >< button class = "btn" > 导出审计记录</ button ></ div ></ div >
2026-08-04 11:49:18 +08:00
< article class = "card" >< div class = "card-head" >< div class = "search" >< svg class = "icon" >< use href = "#i-search" /></ svg >< input class = "field" id = "auditSearch" aria-label = "搜索审计日志" placeholder = "操作、角色或对象编号" ></ div >< span class = "badge info" > 只读</ span ></ div >< div class = "table-wrap" >< table >< thead >< tr >< th > 时间</ th >< th > 角色</ th >< th > 动作</ th >< th > 对象</ th >< th > 结果</ th >< th > 来源</ th ></ tr ></ thead >< tbody id = "auditRows" ></ tbody ></ table ></ div >< div class = "pager" >< span id = "auditPageSummary" > 第 1 / 28 页 · 每页 3 条</ span >< div class = "pager-actions" >< button class = "btn" id = "auditPrev" > 上一页</ button >< button class = "btn" id = "auditNext" > 下一页</ button ></ div ></ div ></ article >
2026-08-04 00:23:55 +08:00
</ section >
</ div >
</ main >
</ div >
< nav class = "mobile-nav" aria-label = "移动端主导航" >< button class = "active" data-view = "duty" > 值班</ button >< button data-view = "events" > 事件</ button >< button data-view = "rules" > 规则</ button >< button data-view = "chains" > 升级</ button >< button data-view = "reports" > 报表</ button ></ nav >
2026-08-04 09:04:52 +08:00
< dialog id = "ruleDialog" aria-labelledby = "ruleTitle" >< form method = "dialog" >< div class = "dialog-head" >< div >< h2 id = "ruleTitle" > 配置告警规则</ h2 >< span class = "muted" > 规则草稿 · 尚未正式生效</ span ></ div >< 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 = "ruleScene" > 场景模板</ label >< select id = "ruleScene" class = "select" >< option > 非授权人员进入</ option >< option > 区域内聚集</ option >< option > 方向越线</ option ></ select ></ div >
< div class = "form-field" >< label for = "ruleScope" > 继承与覆盖</ label >< select id = "ruleScope" class = "select" >< option > 当前设备覆盖</ option >< option > 继承站点模板</ option >< option > 覆盖站点模板</ option ></ select ></ div >
< div class = "form-field" >< label for = "ruleCamera" > 摄像头</ label >< select id = "ruleCamera" class = "select" >< option value = "CAM-S2-001" > 北围墙 01</ option >< option value = "CAM-S2-006" > 实验楼入口 02</ option >< option value = "CAM-S2-011" > 宿舍东通道</ option ></ select ></ div >
2026-08-04 10:05:56 +08:00
< div class = "form-field" >< label for = "ruleZone" > 检测区域</ label >< select id = "ruleZone" class = "select" >< option value = "ZONE-007" > 北围墙危险区域 · v7</ option >< option value = "ZONE-011" > 北围墙方向警戒线 · v3</ option ></ select ></ div >
< div class = "linked-zone" >< span >< strong > 空间坐标由摄像头详情维护</ strong >< small > 区域版本变化不会绕过规则试运行或自动正式发布。</ small ></ span >< a class = "btn" id = "editRuleZone" href = "../sense/index.html?view=device-detail&camera=CAM-S2-001&zone=ZONE-007&return_to=bell-rule" > 编辑检测区域</ a ></ div >
2026-08-04 09:04:52 +08:00
< div class = "form-field" >< label for = "ruleSchedule" > 生效时段</ label >< select id = "ruleSchedule" class = "select" >< option > 全天</ option >< option > 夜间 22:00– 06:00</ option >< option > 自定义课表</ option ></ select ></ div >
< div class = "form-field" >< label for = "ruleDuration" > 持续时间</ label >< select id = "ruleDuration" class = "select" >< option > 超过 3 秒</ option >< option > 立即</ option >< option > 超过 10 秒</ option >< option > 超过 20 秒</ option ></ select ></ div >
< div class = "form-field full" >< label >< input id = "startDryRun" type = "checkbox" checked aria-label = "保存后开始试运行" > 保存后开始试运行,不触发正式预警</ label ></ div >
2026-08-04 11:49:18 +08:00
< div class = "inline-alert danger form-field full" id = "ruleConflict" role = "alert" hidden >< strong > 规则已由周管理员发布为 v8。</ strong >< br > 当前草稿基于 v7,不能覆盖。请刷新到 v8 后重新合并,或另存为新草稿。</ div >
2026-08-04 09:04:52 +08:00
< p class = "workflow-note" > 产品流程:先确认摄像头和检测区域,再配置业务条件;试运行样本达到现场评审要求后,才允许正式发布。</ p >
2026-08-04 11:49:18 +08:00
</ div ></ div >< div class = "dialog-foot" >< button type = "button" class = "btn" id = "simulateRuleConflict" > 演示版本冲突</ button >< button class = "btn" value = "cancel" > 取消</ button >< button class = "btn btn-primary" id = "saveRuleDraft" value = "default" > 保存并开始试运行</ button ></ div ></ form ></ dialog >
2026-08-04 09:04:52 +08:00
2026-08-04 00:23:55 +08:00
< dialog id = "falseDialog" aria-labelledby = "falseTitle" >< form method = "dialog" >< div class = "dialog-head" >< h2 id = "falseTitle" > 标记为误报</ h2 >< button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭对话框" > × </ button ></ div >< div class = "dialog-body" >< p > 这只会追加事件 outcome,不会修改原始事件事实和证据。</ p >< div class = "form-grid" >< div class = "form-field full" >< label for = "falseReason" > 误报原因</ label >< select id = "falseReason" class = "select" required >< option value = "" > 请选择</ option >< option > 区域配置不准确</ option >< option > 目标分类错误</ option >< option > 时段策略不准确</ option >< option > 其他</ option ></ select ></ div >< div class = "form-field full" >< label for = "falseNote" > 补充说明(可选)</ label >< textarea id = "falseNote" class = "textarea" placeholder = "便于算法团队归因,不填写真实个人信息" ></ textarea ></ div >< div class = "form-field full" >< label >< input type = "checkbox" checked > 进入标注复核队列</ label ></ div ></ div ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 取消</ button >< button class = "btn btn-danger" id = "confirmFalse" value = "default" > 确认标记</ button ></ div ></ form ></ dialog >
< dialog id = "muteDialog" aria-labelledby = "muteTitle" >< form method = "dialog" >< div class = "dialog-head" >< h2 id = "muteTitle" > 创建限时静默</ 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 = "muteScope" > 影响范围</ label >< select id = "muteScope" class = "select" >< option > 北围墙 01 · 当前规则</ option >< option > 北围墙区域</ option ></ select ></ div >< div class = "form-field" >< label for = "muteTime" > 持续时间</ label >< select id = "muteTime" class = "select" >< option > 30 分钟</ option >< option > 1 小时</ option >< option > 2 小时</ option >< option > 4 小时(上限)</ option ></ select ></ div >< div class = "form-field full" >< label for = "muteCause" > 原因</ label >< textarea id = "muteCause" class = "textarea" required placeholder = "例如:现场施工维护,已安排人工巡查" ></ textarea ></ div ></ div >< p class = "muted" > 到期自动恢复;S1 高危事件与设备离线运维告警不受此静默影响。</ p ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 取消</ button >< button class = "btn btn-primary" id = "confirmMute" value = "default" > 确认并记录审计</ button ></ div ></ form ></ dialog >
2026-08-04 11:08:41 +08:00
< dialog id = "areaDialog" aria-labelledby = "areaTitle" >< form method = "dialog" >< div class = "dialog-head" >< h2 id = "areaTitle" > 编辑 Area 准入策略</ 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 = "areaName" > Area</ label >< input id = "areaName" class = "field" value = "北围墙" ></ div >< div class = "form-field" >< label for = "areaParent" > 父级</ label >< select id = "areaParent" class = "select" >< option > 校园</ option >< option > 教学区</ option >< option > 宿舍楼</ option ></ select ></ div >< div class = "form-field full" >< label for = "areaPolicy" > capture_policy</ label >< select id = "areaPolicy" class = "select" >< option value = "video_allowed" > 允许成像设备</ option >< option value = "non_imaging_only" > 仅允许非成像设备</ option ></ select ></ div >< div class = "form-field full" >< label for = "areaReason" > 变更原因</ label >< textarea id = "areaReason" class = "textarea" placeholder = "必填,进入全局审计" ></ textarea ></ div ></ div >< p class = "muted" > 保存生成新策略版本;Sense 投影同步前会显示旧版本,不允许绕过后端准入检查。</ p ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 取消</ button >< button type = "button" class = "btn btn-primary" id = "saveAreaPolicy" > 保存策略版本</ button ></ div ></ form ></ dialog >
< dialog id = "policyConflictDialog" aria-labelledby = "policyConflictTitle" >< form method = "dialog" >< div class = "dialog-head" >< h2 id = "policyConflictTitle" > 策略与已有设备冲突</ h2 >< button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭对话框" > × </ button ></ div >< div class = "dialog-body" >< p > 北围墙当前有 4 台成像设备。改为“仅非成像”前必须选择显式处置;系统不会自动停用、移动或保留违规设备。</ p >< label class = "radio-card" >< input type = "radio" name = "conflictAction" value = "move" > < strong > 先迁移 4 台设备到允许成像的 Area</ strong >< br >< span class = "muted" > 创建迁移任务,全部成功后再发布策略。</ span ></ label >< label class = "radio-card" >< input type = "radio" name = "conflictAction" value = "cancel" checked > < strong > 取消本次策略变更</ strong >< br >< span class = "muted" > 保持 v42 和现有设备不变。</ span ></ label ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 返回编辑</ button >< button class = "btn btn-primary" value = "default" id = "confirmPolicyConflict" > 确认选择</ button ></ div ></ form ></ dialog >
2026-08-04 11:49:18 +08:00
< dialog id = "manageDialog" aria-labelledby = "manageTitle" >< form method = "dialog" >< div class = "dialog-head" >< div >< h2 id = "manageTitle" > 管理</ h2 >< span class = "muted" > 移动端保留 5 个主入口,低频管理能力从这里进入。</ span ></ div >< button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭管理菜单" > × </ button ></ div >< div class = "dialog-body" >< div class = "relation-list" >< button type = "button" class = "relation-item" data-manage-view = "sites" >< strong > 站点与 Area</ strong >< span > 配额、capture_policy、RBAC →</ span ></ button >< button type = "button" class = "relation-item" data-manage-view = "audit" >< strong > 审计日志</ strong >< span > 只读、筛选、分页 →</ span ></ button ></ div ></ div ></ form ></ dialog >
< dialog id = "eventDialog" aria-labelledby = "eventDialogTitle" >< form method = "dialog" >< div class = "dialog-head" >< div >< h2 id = "eventDialogTitle" > 事件详情</ h2 >< span class = "muted" id = "eventDialogSubtitle" > 不可变事实</ span ></ div >< button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭事件详情" > × </ button ></ div >< div class = "dialog-body" >< div class = "fact-grid" style = "margin:0 0 14px" >< div class = "fact" >< span > 事件编号</ span >< strong class = "mono" id = "eventDetailId" > —</ strong ></ div >< div class = "fact" >< span > 点位</ span >< strong id = "eventDetailSite" > —</ strong ></ div >< div class = "fact" >< span > 规则</ span >< strong id = "eventDetailRule" > —</ strong ></ div >< div class = "fact" >< span > 证据</ span >< strong id = "eventDetailEvidence" > —</ strong ></ div ></ div >< div class = "relation-panel" style = "margin:0" >< h3 > 触发的预警</ h3 >< div class = "relation-list" id = "eventAlerts" ></ div ></ div ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 关闭</ button ></ div ></ form ></ dialog >
2026-08-04 14:14:06 +08:00
< dialog id = "handoverDialog" aria-labelledby = "handoverTitle" >< form method = "dialog" >< div class = "dialog-head" >< div >< h2 id = "handoverTitle" > 值班交接</ h2 >< span class = "muted" > 白班 · 林值班员 → 晚班</ span ></ div >< 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 = "handoverTo" > 接班人</ label >< select class = "select" id = "handoverTo" >< option value = "" > 请选择并确认本人已在场</ option >< option > 王值班员 · 晚班</ option >< option > 赵值班员 · 晚班</ option ></ select ></ div >< div class = "form-field" >< label for = "handoverAt" > 交接时间</ label >< input class = "field" id = "handoverAt" value = "2026-08-04 20:00" readonly ></ div >< div class = "form-field full" >< label > 待交接 Alert</ label >< div class = "relation-list" >< div class = "relation-item" >< strong > ALT-1842 · 未 ack</ strong >< span > 下一次升级 01:42</ span ></ div >< div class = "relation-item" >< strong > ALT-1841 · 处置中</ strong >< span > 王值班员现场核查</ span ></ div >< div class = "relation-item" >< strong > ALT-1839 · 升级中</ strong >< span > 短信重试 2/3</ span ></ div ></ div ></ div >< div class = "form-field full" >< label for = "handoverNote" > 交接备注</ label >< textarea class = "textarea" id = "handoverNote" placeholder = "记录现场状态、已采取动作和待跟进事项" ></ textarea ></ div ></ div >< p class = "metric-note" > 交接期间升级链继续计时,不暂停、不重置;只转移这些进行中 Alert 的处置责任,不修改未来排班。未来班次变化请在“值班与排班”创建替班版本。</ p ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 取消</ button >< button type = "button" class = "btn btn-primary" id = "confirmHandover" > 接班人确认并留痕</ button ></ div ></ form ></ dialog >
2026-08-04 11:49:18 +08:00
< dialog id = "rollbackDialog" aria-labelledby = "rollbackTitle" >< form method = "dialog" >< div class = "dialog-head" >< div >< h2 id = "rollbackTitle" > 版本历史与回滚</ h2 >< span class = "muted" > RULE-007 · 历史版本不可变</ span ></ div >< button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭版本历史" > × </ button ></ div >< div class = "dialog-body" >< div class = "version-diff" >< article >< strong > 当前 v7 · 2026-08-01 正式发布</ strong >< span class = "muted" > 持续 3 秒 · 全天 · 4 个点位 · 区域 v7</ span ></ article >< article >< strong > 目标 v6 · 2026-07-18 历史版本</ strong >< span class = "muted" > 持续 5 秒 · 夜间 22:00–06:00 · 3 个点位 · 区域 v6</ span ></ article >< article >< strong > 影响评估</ strong >< span class = "muted" > 将减少 1 个点位并收窄时段;预计每日预警 -18%。确认后创建并发布 v8,使其配置等价于 v6,v6/v7 均保持不变。</ span ></ article ></ div ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 取消</ button >< button type = "button" class = "btn btn-danger" id = "confirmRollback" > 创建并发布回滚版本 v8</ button ></ div ></ form ></ dialog >
2026-08-04 14:14:06 +08:00
< dialog id = "escalationDialog" aria-labelledby = "escalationTitle" >< form method = "dialog" >< div class = "dialog-head" >< div >< h2 id = "escalationTitle" > 编辑升级步骤</ h2 >< span class = "muted" > 默认链 v5 草稿 · 不直接保存手机号</ span ></ div >< 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 = "escalationTargetType" > 目标类型</ label >< select class = "select" id = "escalationTargetType" >< option value = "schedule" > 值班排班</ option >< option value = "team" > 固定值班组</ option >< option value = "person" > 指定人员</ option ></ select ></ div >< div class = "form-field" >< label for = "escalationTargetRef" > 目标</ label >< select class = "select" id = "escalationTargetRef" ></ select ></ div >< div class = "form-field" >< label for = "escalationChannel" > 首选通道</ label >< select class = "select" id = "escalationChannel" >< option > 短信 + 语音兜底</ option >< option > Web + 本地声光</ option >< option > 语音 + 短信兜底</ option ></ select ></ div >< div class = "form-field" >< label for = "escalationTimeout" > 等待 ack</ label >< select class = "select" id = "escalationTimeout" >< option > 2 分钟</ option >< option > 3 分钟</ option >< option > 5 分钟</ option ></ select ></ div >< div class = "form-field full" >< label > 当前解析预览</ label >< div class = "resolve-item" id = "escalationPreview" aria-live = "polite" ></ div ></ div ></ div >< p class = "workflow-note" > 保存的是类型化目标引用与通道偏好。每次投递创建时按站点时区解析生效排班,再固化收件人、通道和排班版本快照。</ p ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 取消</ button >< button type = "button" class = "btn btn-primary" id = "saveEscalation" > 保存策略草稿</ button ></ div ></ form ></ dialog >
< dialog id = "scheduleDialog" aria-labelledby = "scheduleTitle" >< form method = "dialog" >< div class = "dialog-head" >< div >< h2 id = "scheduleTitle" > 发布值班排班</ h2 >< span class = "muted" > 历史版本不可变</ span ></ div >< 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 = "scheduleName" > 排班计划</ label >< input class = "field" id = "scheduleName" value = "校园安保值班表" readonly ></ div >< div class = "form-field" >< label for = "scheduleNextVersion" > 新版本</ label >< input class = "field" id = "scheduleNextVersion" value = "v13" readonly ></ div >< div class = "form-field" >< label for = "scheduleTimezone" > 站点时区</ label >< select class = "select" id = "scheduleTimezone" >< option > Asia/Shanghai</ option ></ select ></ div >< div class = "form-field" >< label for = "scheduleEffective" > 生效时间</ label >< input class = "field" id = "scheduleEffective" type = "datetime-local" value = "2026-08-05T20:00" ></ div >< div class = "form-field full" >< label for = "scheduleReason" > 发布说明</ label >< textarea class = "textarea" id = "scheduleReason" placeholder = "必填,例如:王值班员临时替班,覆盖周三晚班" ></ textarea ></ div ></ div >< p class = "metric-note" > 发布前重新检查空档、重叠、联系人停用和通道验证。新版本只影响生效时间后的投递,不回写 v12 或既有投递快照。</ p ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 取消</ button >< button type = "button" class = "btn btn-primary" id = "confirmSchedulePublish" > 确认发布 v13</ button ></ div ></ form ></ dialog >
< dialog id = "substituteDialog" aria-labelledby = "substituteTitle" >< form method = "dialog" >< div class = "dialog-head" >< div >< h2 id = "substituteTitle" > 创建临时替班</ h2 >< span class = "muted" > 保存到 v13 草稿,发布后生效</ span ></ div >< 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 = "substituteShift" > 被替换班次</ label >< select class = "select" id = "substituteShift" >< option > 周三晚班 · 周值班员 · 20:00–08:00</ option >< option > 周四白班 · 林值班员 · 08:00–20:00</ option ></ select ></ div >< div class = "form-field" >< label for = "substitutePerson" > 替班人</ label >< select class = "select" id = "substitutePerson" >< option value = "" > 请选择可用联系人</ option >< option > 王值班员</ option >< option > 赵值班员</ option ></ select ></ div >< div class = "form-field full" >< label for = "substituteReason" > 替班原因</ label >< textarea class = "textarea" id = "substituteReason" placeholder = "必填,写入排班版本审计" ></ textarea ></ div ></ div >< p class = "workflow-note" > 临时替班修改未来排班。若只是把正在处置的 Alert 交给下一班,请使用值班台“交接班”,不要改排班。</ p ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 取消</ button >< button type = "button" class = "btn btn-primary" id = "saveSubstitute" > 保存到 v13 草稿</ button ></ div ></ form ></ dialog >
< dialog id = "contactDialog" aria-labelledby = "contactTitle" >< form method = "dialog" >< div class = "dialog-head" >< div >< h2 id = "contactTitle" > 新建联系人</ h2 >< span class = "muted" > 联系人不包含班次和轮换字段</ span ></ div >< 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 = "contactName" > 姓名</ label >< input class = "field" id = "contactName" placeholder = "联系人显示名" ></ div >< div class = "form-field" >< label for = "contactRole" > 角色</ label >< select class = "select" id = "contactRole" >< option > 值班员</ option >< option > 站点负责人</ option >< option > 租户管理员</ option ></ select ></ div >< div class = "form-field" >< label for = "contactTeam" > 值班组</ label >< select class = "select" id = "contactTeam" >< option > 校园安保组</ option >< option > 校级负责人组</ option ></ select ></ div >< div class = "form-field" >< label for = "contactPhone" > 新通知号码</ label >< input class = "field" id = "contactPhone" type = "tel" placeholder = "输入后发送验证码;不回显旧值" ></ div >< div class = "form-field full" >< label > 启用通道</ label >< label class = "radio-card" >< input type = "checkbox" id = "contactSms" checked > 短信(需验证)</ label >< label class = "radio-card" >< input type = "checkbox" id = "contactVoice" > 语音(需验证)</ label >< label class = "radio-card" >< input type = "checkbox" id = "contactWeb" checked > Web 站内通知</ label ></ div ></ div >< p class = "metric-note" > 联系人保存身份、角色、值班组与已验证通道。加入哪个班次、何时轮换由“值班与排班”模块引用联系人 ID 完成。</ p ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 取消</ button >< button type = "button" class = "btn btn-primary" id = "saveContact" > 保存并发起通道验证</ button ></ div ></ form ></ dialog >
2026-08-04 00:23:55 +08:00
< 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 )];
2026-08-04 11:49:18 +08:00
const toast = $ ( '#toast' );
let toastTimer ;
function notify ( message ) {
toast . textContent = message ;
toast . classList . add ( 'show' );
clearTimeout ( toastTimer );
toastTimer = setTimeout (() => toast . classList . remove ( 'show' ), 3800 );
}
2026-08-04 00:23:55 +08:00
const alerts = [
2026-08-04 11:49:18 +08:00
{ id : 'ALT-1842' , severity : 'S1 高危' , badge : 'danger' , title : '危险区域闯入' , site : '北围墙 01' , time : '23:14:08' , age : '1 分钟' , rule : '非授权人员进入' , delivery : '短信等待回执' , events : [{ id : 'EVT-01J8…7A2' , reason : '同点位 30 秒窗口聚合' },{ id : 'EVT-01J8…7B4' , reason : '同规则连续命中聚合' }]},
{ id : 'ALT-1841' , severity : 'S1 高危' , badge : 'danger' , title : '方向越线' , site : '宿舍东通道' , time : '23:12:46' , age : '3 分钟' , rule : '夜间越线 A → B' , delivery : '声光已看到' , events : [{ id : 'EVT-01J8…71M' , reason : '单事件触发' }]},
{ id : 'ALT-1839' , severity : 'S2 重要' , badge : 'warning' , title : '区域聚集' , site : '操场西北角' , time : '23:08:21' , age : '7 分钟' , rule : '人数 ≥ 6 持续 20 秒' , delivery : 'Web 已看到' , events : [{ id : 'EVT-01J8…52C' , reason : '同站点 60 秒窗口聚合' }]},
{ id : 'ALT-1836' , severity : 'S2 重要' , badge : 'warning' , title : '危险区域滞留' , site : '配电房门口' , time : '22:57:03' , age : '18 分钟' , rule : '区域内停留 ≥ 10 秒' , delivery : '短信最终失败' , events : [{ id : 'EVT-01J8…44K' , reason : '单事件触发' }]}
2026-08-04 00:23:55 +08:00
];
2026-08-04 11:49:18 +08:00
const eventRecords = [
{ id : 'EVT-01J8…7A2' , time : '23:14:08' , site : '北围墙 01' , rule : '非授权人员进入' , fact : '已确认' , factBadge : 'danger' , result : '处置中' , evidence : '可用' , evidenceBadge : 'success' , alerts : [{ id : 'ALT-1842' , state : '等待 ack · 聚合 2 个事件' }]},
{ id : 'EVT-01J8…7B4' , time : '23:14:21' , site : '北围墙 01' , rule : '非授权人员进入' , fact : '已确认' , factBadge : 'danger' , result : '已聚合' , evidence : '可用' , evidenceBadge : 'success' , alerts : [{ id : 'ALT-1842' , state : '等待 ack · 同点位窗口聚合' }]},
{ id : 'EVT-01J8…69P' , time : '22:48:31' , site : '宿舍东通道' , rule : '区域内聚集' , fact : '已记录' , factBadge : 'info' , result : '试运行样本' , evidence : '可用' , evidenceBadge : 'success' , alerts : []},
{ id : 'EVT-01J8…52C' , time : '22:19:04' , site : '实验楼入口' , rule : '危险区域闯入' , fact : '已确认' , factBadge : 'danger' , result : '误报' , evidence : '重试中' , evidenceBadge : 'warning' , alerts : [{ id : 'ALT-1839' , state : '处置完成 · 误报' }]},
{ id : 'EVT-01J8…71M' , time : '21:58:20' , site : '宿舍东通道' , rule : '夜间越线 A → B' , fact : '已确认' , factBadge : 'danger' , result : '处置中' , evidence : '可用' , evidenceBadge : 'success' , alerts : [{ id : 'ALT-1841' , state : '已 ack · 现场核查' }]},
{ id : 'EVT-01J8…44K' , time : '21:31:02' , site : '配电房门口' , rule : '区域内停留 ≥ 10 秒' , fact : '已确认' , factBadge : 'warning' , result : '升级中' , evidence : '仅抓拍' , evidenceBadge : 'warning' , alerts : [{ id : 'ALT-1836' , state : '短信最终失败 · 语音待发送' }]}
];
const auditRecords = [
{ time : '23:14:18' , role : '站点值班员' , action : 'ACK_ALERT' , object : 'ALT-1842' , result : '成功' , badge : 'success' , source : '值班室 Web' },
{ time : '22:52:06' , role : '站点管理员' , action : 'PUBLISH_RULE' , object : 'RULE-007' , result : '成功' , badge : 'success' , source : '管理端' },
{ time : '22:20:31' , role : '只读用户' , action : 'VIEW_EVIDENCE' , object : 'EVT-01J8…52C' , result : '拒绝' , badge : 'danger' , source : '响应式 H5' },
{ time : '21:56:04' , role : '站点值班员' , action : 'HANDOVER_SHIFT' , object : 'SHIFT-20260804-DAY' , result : '成功' , badge : 'success' , source : '值班室 Web' },
{ time : '21:41:12' , role : '租户管理员' , action : 'ROLLBACK_RULE' , object : 'RULE-007:v8' , result : '成功' , badge : 'success' , source : '管理端' },
{ time : '20:05:55' , role : '站点管理员' , action : 'UPDATE_AREA_POLICY' , object : 'area-lab-gate:v41' , result : '成功' , badge : 'success' , source : '管理端' }
];
function makeTextCell ( row , value , className = '' ) {
const cell = document . createElement ( 'td' );
cell . textContent = value ;
if ( className ) cell . className = className ;
row . append ( cell );
return cell ;
}
function makeBadgeCell ( row , value , badge ) {
const cell = document . createElement ( 'td' );
const span = document . createElement ( 'span' );
span . className = `badge ${ badge } ` ;
span . textContent = value ;
cell . append ( span );
row . append ( cell );
}
let selectedAlertIndex = 0 ;
let ackOwner = null ;
2026-08-04 00:23:55 +08:00
const alertList = $ ( '#alertList' );
2026-08-04 11:49:18 +08:00
function renderAlerts ( selected = selectedAlertIndex ) {
alertList . innerHTML = alerts . map (( alert , index ) => `<button class="alert-card ${ index === selected ? 'selected' : '' } " data-index=" ${ index } "><div class="alert-card-top"><span class="badge ${ alert . badge } "><i class="severity-dot"></i> ${ alert . severity } </span><span class="muted"> ${ alert . age } </span></div><h3> ${ alert . title } </h3><p> ${ alert . site } · ${ alert . time } </p><div class="alert-card-foot"><span> ${ alert . delivery } </span><span class="mono"> ${ alert . id } </span></div></button>` ). join ( '' );
$$ ( '.alert-card' , alertList ). forEach ( button => button . addEventListener ( 'click' , () => selectAlert ( Number ( button . dataset . index ))));
}
function renderRelatedEvents ( alert ) {
const container = $ ( '#relatedEvents' );
container . replaceChildren ();
alert . events . forEach ( event => {
const button = document . createElement ( 'button' );
button . type = 'button' ;
button . className = 'relation-item' ;
const strong = document . createElement ( 'strong' );
strong . className = 'mono' ;
strong . textContent = event . id ;
const reason = document . createElement ( 'span' );
reason . textContent = ` ${ event . reason } →` ;
button . append ( strong , reason );
button . addEventListener ( 'click' , () => showEventDetail ( event . id ));
container . append ( button );
});
$ ( '#relatedEventCount' ). textContent = String ( alert . events . length );
}
function resetAck () {
ackOwner = null ;
$ ( '#ackState' ). className = 'badge warning' ;
$ ( '#ackState' ). textContent = '等待 ack' ;
$ ( '#ackButton' ). disabled = false ;
$ ( '#ackButton' ). innerHTML = '<svg class="icon"><use href="#i-check"/></svg>确认接手' ;
$ ( '#ackHelp' ). textContent = '一次点击确认;并发确认时会显示实际接手人,不覆盖对方结果。' ;
$ ( '#ackConflict' ). hidden = true ;
}
function selectAlert ( index ) {
selectedAlertIndex = index ;
renderAlerts ( index );
const alert = alerts [ index ];
$ ( '#selectedSeverity' ). className = `badge ${ alert . badge } ` ;
$ ( '#selectedSeverity' ). textContent = alert . severity ;
$ ( '#selectedTitle' ). textContent = alert . title ;
$ ( '#selectedMeta' ). textContent = ` ${ alert . site } · ${ alert . time } · ${ alert . id } ` ;
$ ( '#factRule' ). textContent = alert . rule ;
renderRelatedEvents ( alert );
resetAck ();
}
function openAlert ( alertId ) {
const index = alerts . findIndex ( alert => alert . id === alertId );
if ( index < 0 ) {
notify ( `预警 ${ alertId } 不在当前队列,请从历史预警查询。` );
return ;
}
if ( $ ( '#eventDialog' ). open ) $ ( '#eventDialog' ). close ();
switchView ( 'duty' );
selectAlert ( index );
notify ( `已从事件打开关联预警 ${ alertId } 。` );
}
2026-08-04 00:23:55 +08:00
2026-08-04 11:49:18 +08:00
function showEventDetail ( eventId ) {
const event = eventRecords . find ( item => item . id === eventId ) || eventRecords [ 0 ];
$ ( '#eventDialogTitle' ). textContent = ` ${ event . id } · 事件详情` ;
$ ( '#eventDialogSubtitle' ). textContent = ` ${ event . time } · 事实不可由处置改写` ;
$ ( '#eventDetailId' ). textContent = event . id ;
$ ( '#eventDetailSite' ). textContent = event . site ;
$ ( '#eventDetailRule' ). textContent = event . rule ;
$ ( '#eventDetailEvidence' ). textContent = event . evidence ;
const container = $ ( '#eventAlerts' );
container . replaceChildren ();
if ( ! event . alerts . length ) {
const message = document . createElement ( 'div' );
message . className = 'inline-alert' ;
message . textContent = '未创建 Alert:当前为试运行样本或已被规则抑制;不伪装成投递成功。' ;
container . append ( message );
} else {
event . alerts . forEach ( alert => {
const button = document . createElement ( 'button' );
button . type = 'button' ;
button . className = 'relation-item' ;
const strong = document . createElement ( 'strong' );
strong . className = 'mono' ;
strong . textContent = alert . id ;
const state = document . createElement ( 'span' );
state . textContent = ` ${ alert . state } →` ;
button . append ( strong , state );
button . addEventListener ( 'click' , () => openAlert ( alert . id ));
container . append ( button );
});
}
$ ( '#eventDialog' ). showModal ();
}
2026-08-04 00:23:55 +08:00
2026-08-04 11:49:18 +08:00
let eventPage = 1 ;
const eventTotalPages = 16 ;
function renderEventPage () {
const start = (( eventPage - 1 ) * 3 ) % eventRecords . length ;
const pageItems = [ 0 , 1 , 2 ]. map ( offset => eventRecords [( start + offset ) % eventRecords . length ]);
const body = $ ( '#eventRows' );
body . replaceChildren ();
pageItems . forEach ( event => {
const row = document . createElement ( 'tr' );
const idCell = document . createElement ( 'td' );
const eventButton = document . createElement ( 'button' );
eventButton . type = 'button' ;
eventButton . className = 'text-button mono' ;
eventButton . textContent = event . id ;
eventButton . addEventListener ( 'click' , () => showEventDetail ( event . id ));
idCell . append ( eventButton );
row . append ( idCell );
makeTextCell ( row , event . time );
makeTextCell ( row , event . site );
makeTextCell ( row , event . rule );
makeBadgeCell ( row , event . fact , event . factBadge );
const alertCell = document . createElement ( 'td' );
if ( event . alerts . length ) {
event . alerts . forEach ( alert => {
const button = document . createElement ( 'button' );
button . type = 'button' ;
button . className = 'text-button mono' ;
button . textContent = alert . id ;
button . addEventListener ( 'click' , () => openAlert ( alert . id ));
alertCell . append ( button );
});
} else {
alertCell . textContent = '未触发' ;
}
row . append ( alertCell );
makeTextCell ( row , event . result );
makeBadgeCell ( row , event . evidence , event . evidenceBadge );
body . append ( row );
});
$ ( '#eventPageSummary' ). textContent = `第 ${ eventPage } / ${ eventTotalPages } 页 · 每页 3 条 · 共 47 条` ;
$ ( '#eventPrev' ). disabled = eventPage === 1 ;
$ ( '#eventNext' ). disabled = eventPage === eventTotalPages ;
}
2026-08-04 00:23:55 +08:00
2026-08-04 11:49:18 +08:00
let auditPage = 1 ;
const auditTotalPages = 28 ;
function renderAuditPage () {
const start = (( auditPage - 1 ) * 3 ) % auditRecords . length ;
const pageItems = [ 0 , 1 , 2 ]. map ( offset => auditRecords [( start + offset ) % auditRecords . length ]);
const body = $ ( '#auditRows' );
body . replaceChildren ();
pageItems . forEach ( item => {
const row = document . createElement ( 'tr' );
makeTextCell ( row , item . time );
makeTextCell ( row , item . role );
makeTextCell ( row , item . action );
makeTextCell ( row , item . object , 'mono' );
makeBadgeCell ( row , item . result , item . badge );
makeTextCell ( row , item . source );
body . append ( row );
});
$ ( '#auditPageSummary' ). textContent = `第 ${ auditPage } / ${ auditTotalPages } 页 · 每页 3 条 · 共 84 条` ;
$ ( '#auditPrev' ). disabled = auditPage === 1 ;
$ ( '#auditNext' ). disabled = auditPage === auditTotalPages ;
}
2026-08-04 00:23:55 +08:00
2026-08-04 11:49:18 +08:00
const validViews = new Set ( $$ ( '.view' ). map ( view => view . dataset . page ));
function switchView ( requested ) {
const name = validViews . has ( requested ) ? requested : 'duty' ;
$$ ( '.view' ). forEach ( view => view . classList . toggle ( 'active' , view . dataset . page === name ));
$$ ( '[data-view]' ). forEach ( button => {
const active = button . dataset . view === name ;
button . toggleAttribute ( 'aria-current' , active );
button . classList . toggle ( 'active' , active );
});
$ ( '#stateSelect' ). value = 'normal' ;
renderState ( 'normal' );
$ ( '#main-content' ). focus ({ preventScroll : true });
}
$$ ( '[data-view]' ). forEach ( button => button . addEventListener ( 'click' , () => switchView ( button . dataset . view )));
$$ ( '.queue-tabs button' ). forEach ( button => button . addEventListener ( 'click' , () => {
$$ ( '.queue-tabs button' ). forEach ( item => { const active = item === button ; item . classList . toggle ( 'active' , active ); item . setAttribute ( 'aria-pressed' , String ( active )); });
notify ( `队列已切换到“ ${ button . textContent } ”` );
}));
$$ ( '.evidence-tabs button' ). forEach ( button => button . addEventListener ( 'click' , () => {
$$ ( '.evidence-tabs button' ). forEach ( item => { const active = item === button ; item . classList . toggle ( 'active' , active ); item . setAttribute ( 'aria-pressed' , String ( active )); });
notify ( `正在演示“ ${ button . textContent } ”标签结构` );
}));
function renderState ( state ) {
const layer = $ ( '#stateLayer' );
const views = $$ ( '.view' );
layer . classList . toggle ( 'show' , state !== 'normal' );
views . forEach ( view => { view . style . display = state === 'normal' ? '' : 'none' ; });
if ( state === 'normal' ) return ;
const states = {
loading : '<div><div class="skeleton" aria-label="正在加载"><i></i><i></i><i></i><i></i></div><h2>正在读取最新预警状态</h2><p>超过 300ms 显示进度;ack 和升级事实始终从服务端重新读取。</p></div>' ,
weak : '<div><svg class="icon" style="color:var(--warning)"><use href="#i-alert"/></svg><h2>结构化事件已到达,视频证据暂不可用</h2><p>弱网下先显示时间、点位、规则和升级链。视频片段可单独重试,不阻塞确认接手。</p><button class="btn" data-state-recover="weak">后台重试证据</button></div>' ,
empty : '<div><svg class="icon"><use href="#i-check"/></svg><h2>当前没有待处置预警</h2><p>系统在线,新的 Alert 会实时进入队列。历史事件仍可从事件中心查询。</p><button class="btn" data-empty-events>查看事件中心</button></div>' ,
denied : '<div><svg class="icon" style="color:var(--danger)"><use href="#i-lock"/></svg><h2>没有查看此站点证据的权限</h2><p>当前账号只能查看被授权站点。系统不会泄露事件是否存在、流地址或设备信息。</p><button class="btn" data-state-recover="denied">返回可访问范围</button></div>' ,
expired : '<div><svg class="icon" style="color:var(--danger)"><use href="#i-lock"/></svg><h2>会话已过期</h2><p>为保护预警和证据,页面已停止提交 ack 与处置。重新登录后从服务端读取最新责任人和升级状态,不重放旧操作。</p><button class="btn" data-state-recover="expired">重新登录并刷新状态</button></div>'
};
layer . innerHTML = states [ state ];
$ ( '[data-state-recover]' , layer ) ? . addEventListener ( 'click' , () => { $ ( '#stateSelect' ). value = 'normal' ; renderState ( 'normal' ); notify ( state === 'weak' ? '证据重试任务已加入后台队列。' : '已刷新当前权限与服务端状态。' ); });
$ ( '[data-empty-events]' , layer ) ? . addEventListener ( 'click' , () => switchView ( 'events' ));
}
$ ( '#stateSelect' ). addEventListener ( 'change' , event => renderState ( event . target . value ));
$ ( '#themeToggle' ). addEventListener ( 'click' , () => { const root = document . documentElement ; root . dataset . theme = root . dataset . theme === 'dark' ? 'light' : 'dark' ; notify ( `已切换为 ${ root . dataset . theme === 'dark' ? '深色' : '浅色' } 主题` ); });
$ ( '#eventPrev' ). addEventListener ( 'click' , () => { if ( eventPage > 1 ) { eventPage -= 1 ; renderEventPage (); } });
$ ( '#eventNext' ). addEventListener ( 'click' , () => { if ( eventPage < eventTotalPages ) { eventPage += 1 ; renderEventPage (); } });
$ ( '#auditPrev' ). addEventListener ( 'click' , () => { if ( auditPage > 1 ) { auditPage -= 1 ; renderAuditPage (); } });
$ ( '#auditNext' ). addEventListener ( 'click' , () => { if ( auditPage < auditTotalPages ) { auditPage += 1 ; renderAuditPage (); } });
$ ( '#ackButton' ). addEventListener ( 'click' , async event => {
const button = event . currentTarget ;
button . disabled = true ;
button . textContent = '确认中…' ;
await new Promise ( resolve => setTimeout ( resolve , 650 ));
if ( ackOwner && ackOwner !== 'current' ) {
button . textContent = '当前预警已由他人接手' ;
$ ( '#ackConflict' ). hidden = false ;
$ ( '#ackConflict' ). textContent = '确认未生效:王值班员已于 23:15:01 抢先 ack。已刷新为服务端结果,没有覆盖对方。' ;
notify ( '并发确认未覆盖:请与当前处置人协作。' );
return ;
}
ackOwner = 'current' ;
button . innerHTML = '<svg class="icon"><use href="#i-check"/></svg>已由当前值班员接手' ;
$ ( '#ackState' ). className = 'badge success' ;
$ ( '#ackState' ). textContent = '已 ack' ;
$ ( '#ackHelp' ). textContent = '23:15:02 · 当前值班员 · 服务端确认成功;通道回执仍单独保留。' ;
notify ( '确认成功,升级计时已停止并记录审计。' );
});
$ ( '#simulateAckConflict' ). addEventListener ( 'click' , () => {
ackOwner = 'other' ;
$ ( '#ackButton' ). disabled = true ;
$ ( '#ackButton' ). textContent = '已由王值班员接手' ;
$ ( '#ackState' ). className = 'badge info' ;
$ ( '#ackState' ). textContent = '已 ack · 他人' ;
$ ( '#ackHelp' ). textContent = '23:15:01 · 王值班员 · 服务端首个成功结果' ;
$ ( '#ackConflict' ). hidden = false ;
$ ( '#ackConflict' ). textContent = '你的 ack 未生效:王值班员抢先成功。页面已刷新实际处置人,没有静默覆盖或显示双成功。' ;
});
$ ( '#completeAlert' ). addEventListener ( 'click' , () => {
if ( ! $ ( '#outcome' ). value ) { notify ( '请先选择处置结果。' ); $ ( '#outcome' ). focus (); return ; }
if ( ackOwner !== 'current' ) { notify ( ackOwner === 'other' ? '当前处置人为王值班员,你不能覆盖其处置结果。' : '请先确认接手,再完成处置。' ); $ ( '#ackButton' ). focus (); return ; }
notify ( '处置结果已追加,原始事件事实未被修改。' );
});
$ ( '#falsePositive' ). addEventListener ( 'click' , () => $ ( '#falseDialog' ). showModal ());
$ ( '#confirmFalse' ). addEventListener ( 'click' , event => { if ( ! $ ( '#falseReason' ). value ) { event . preventDefault (); notify ( '请选择误报原因。' ); $ ( '#falseReason' ). focus (); return ; } notify ( '已标记误报并进入复核队列,原始事件保持不变。' ); });
$ ( '#muteButton' ). addEventListener ( 'click' , () => $ ( '#muteDialog' ). showModal ());
$ ( '#confirmMute' ). addEventListener ( 'click' , event => { if ( ! $ ( '#muteCause' ). value . trim ()) { event . preventDefault (); notify ( '请填写静默原因。' ); $ ( '#muteCause' ). focus (); return ; } notify ( '已创建限时静默,到期将自动恢复并通知值班组。' ); });
$ ( '#handoverButton' ). addEventListener ( 'click' , () => $ ( '#handoverDialog' ). showModal ());
$ ( '#confirmHandover' ). addEventListener ( 'click' , () => { if ( ! $ ( '#handoverTo' ). value ) { notify ( '请选择接班人并由本人确认。' ); $ ( '#handoverTo' ). focus (); return ; } $ ( '#handoverDialog' ). close (); notify ( '交接已由接班人确认并写入审计;3 条 Alert 的升级链继续运行。' ); });
2026-08-04 14:14:06 +08:00
function switchChainSection ( name ) {
const target = [ 'strategy' , 'roster' , 'contacts' ]. includes ( name ) ? name : 'strategy' ;
$$ ( '[data-chain-panel]' ). forEach ( panel => panel . classList . toggle ( 'active' , panel . dataset . chainPanel === target ));
$$ ( '[data-chain-section]' ). forEach ( button => {
const active = button . dataset . chainSection === target ;
button . classList . toggle ( 'active' , active );
button . setAttribute ( 'aria-pressed' , String ( active ));
});
}
$$ ( '[data-chain-section]' ). forEach ( button => button . addEventListener ( 'click' , () => switchChainSection ( button . dataset . chainSection )));
const escalationTargets = {
schedule : [
{ value : 'SCH-CAMPUS-SECURITY' , label : '校园安保值班表 · v12' , preview : '王值班员 · 晚班 · 短信/语音已验证' },
{ value : 'SCH-SCHOOL-LEAD' , label : '校级负责人轮值表 · v6' , preview : '陈负责人 · 当前轮值 · 语音已验证' }
],
team : [
{ value : 'DUTY-ROOM' , label : '值班室固定组' , preview : '4 人在线 · Web + 本地声光' },
{ value : 'SCHOOL-LEADS' , label : '校级负责人组' , preview : '3 名成员 · 顺序通知' }
],
person : [
{ value : 'USR-SCHOOL-LEAD' , label : '校级负责人' , preview : '指定人员 · 短信已验证 · 语音待验证' },
{ value : 'USR-DUTY-WANG' , label : '王值班员' , preview : '指定人员 · 短信/语音/Web 已验证' }
]
};
function renderEscalationTargets () {
const type = $ ( '#escalationTargetType' ). value ;
const select = $ ( '#escalationTargetRef' );
select . replaceChildren ();
escalationTargets [ type ]. forEach ( target => {
const option = document . createElement ( 'option' );
option . value = target . value ;
option . textContent = target . label ;
select . append ( option );
});
renderEscalationPreview ();
}
function renderEscalationPreview () {
const type = $ ( '#escalationTargetType' ). value ;
const target = escalationTargets [ type ]. find ( item => item . value === $ ( '#escalationTargetRef' ). value ) || escalationTargets [ type ][ 0 ];
const typeLabel = type === 'schedule' ? '值班排班' : type === 'team' ? '固定值班组' : '指定人员' ;
$ ( '#escalationPreview' ). textContent = ` ${ typeLabel } · ${ target . label } → ${ target . preview } 。实际投递时固化解析快照。` ;
}
$ ( '#editEscalation' ). addEventListener ( 'click' , () => { renderEscalationTargets (); $ ( '#escalationDialog' ). showModal (); });
$ ( '#escalationTargetType' ). addEventListener ( 'change' , renderEscalationTargets );
$ ( '#escalationTargetRef' ). addEventListener ( 'change' , renderEscalationPreview );
$ ( '#saveEscalation' ). addEventListener ( 'click' , () => { $ ( '#escalationDialog' ). close (); notify ( '升级策略草稿已保存为类型化目标引用;未复制联系人号码。' ); });
let rosterHasConflict = false ;
$ ( '#simulateRosterConflict' ). addEventListener ( 'click' , event => {
rosterHasConflict = ! rosterHasConflict ;
$ ( '#rosterConflict' ). hidden = ! rosterHasConflict ;
$ ( '#rosterConflict' ). textContent = rosterHasConflict ? '发布已阻止:周三 20:00–21:00 存在排班重叠,同时安排了周值班员和王值班员;周四 08:00–09:00 存在空档。请调整后重新检查。' : '' ;
event . currentTarget . textContent = rosterHasConflict ? '恢复无冲突排班' : '演示冲突检查' ;
$ ( '#openSchedulePublish' ). disabled = rosterHasConflict ;
notify ( rosterHasConflict ? '发现 1 个重叠和 1 个空档,排班不能发布。' : '冲突已修复,未来 14 天覆盖完整。' );
});
$ ( '#openSchedulePublish' ). addEventListener ( 'click' , () => { if ( rosterHasConflict ) { notify ( '请先修复排班空档和重叠。' ); $ ( '#rosterConflict' ). focus (); return ; } $ ( '#scheduleDialog' ). showModal (); });
$ ( '#confirmSchedulePublish' ). addEventListener ( 'click' , () => {
if ( ! $ ( '#scheduleReason' ). value . trim ()) { notify ( '请填写排班发布说明。' ); $ ( '#scheduleReason' ). focus (); return ; }
$ ( '#scheduleDialog' ). close ();
$ ( '#scheduleVersion' ). textContent = 'v13' ;
notify ( '排班 v13 已发布,将在设定时间生效;v12 与既有投递快照保持不变。' );
});
$ ( '#openSubstitute' ). addEventListener ( 'click' , () => $ ( '#substituteDialog' ). showModal ());
$ ( '#saveSubstitute' ). addEventListener ( 'click' , () => {
if ( ! $ ( '#substitutePerson' ). value ) { notify ( '请选择替班人。' ); $ ( '#substitutePerson' ). focus (); return ; }
if ( ! $ ( '#substituteReason' ). value . trim ()) { notify ( '请填写替班原因。' ); $ ( '#substituteReason' ). focus (); return ; }
$ ( '#substituteDialog' ). close ();
$ ( '#wednesdayNightShift' ). textContent = ` ${ $ ( '#substitutePerson' ). value } (替班草稿)` ;
$ ( '#scheduleVersion' ). textContent = 'v13 草稿' ;
notify ( '临时替班已保存到 v13 草稿,发布前不会影响实际通知。' );
});
let editingContact = false ;
function openContactEditor ( key = '' ) {
editingContact = Boolean ( key );
const names = { wang : '王值班员' , lin : '林值班员' , lead : '校级负责人' };
$ ( '#contactTitle' ). textContent = editingContact ? `编辑联系人 · ${ names [ key ] } ` : '新建联系人' ;
$ ( '#contactName' ). value = editingContact ? names [ key ] : '' ;
$ ( '#contactPhone' ). value = '' ;
$ ( '#contactPhone' ). placeholder = editingContact ? '留空则保留已验证号码;旧值不回显' : '输入后发送验证码;不回显旧值' ;
$ ( '#contactDialog' ). showModal ();
}
$ ( '#openContact' ). addEventListener ( 'click' , () => openContactEditor ());
$$ ( '[data-edit-contact]' ). forEach ( button => button . addEventListener ( 'click' , () => openContactEditor ( button . dataset . editContact )));
$ ( '#saveContact' ). addEventListener ( 'click' , () => {
if ( ! $ ( '#contactName' ). value . trim ()) { notify ( '请填写联系人姓名。' ); $ ( '#contactName' ). focus (); return ; }
const needsPhone = $ ( '#contactSms' ). checked || $ ( '#contactVoice' ). checked ;
if ( ! editingContact && needsPhone && ! $ ( '#contactPhone' ). value . trim ()) { notify ( '短信或语音通道需要填写新号码并完成验证。' ); $ ( '#contactPhone' ). focus (); return ; }
$ ( '#contactDialog' ). close ();
if ( ! editingContact ) $ ( '#contactCount' ). textContent = '7' ;
notify ( editingContact ? '联系人主数据已更新;既有投递快照未改变。' : '联系人已保存,短信/语音通道进入验证流程;尚未加入任何班次。' );
});
2026-08-04 11:49:18 +08:00
$ ( '#mobileManage' ). addEventListener ( 'click' , () => $ ( '#manageDialog' ). showModal ());
$$ ( '[data-manage-view]' ). forEach ( button => button . addEventListener ( 'click' , () => { $ ( '#manageDialog' ). close (); switchView ( button . dataset . manageView ); notify ( `已进入 ${ button . textContent . trim () } 。` ); }));
$$ ( '[data-edit-area]' ). forEach ( button => button . addEventListener ( 'click' , () => { const isDorm = button . dataset . editArea === 'dorm' ; $ ( '#areaName' ). value = isDorm ? '宿舍卧室' : button . dataset . editArea === 'lab' ? '实验楼入口' : '北围墙' ; $ ( '#areaPolicy' ). value = isDorm ? 'non_imaging_only' : 'video_allowed' ; $ ( '#areaDialog' ). showModal (); }));
$ ( '#newArea' ). addEventListener ( 'click' , () => { $ ( '#areaName' ). value = '' ; $ ( '#areaPolicy' ). value = 'video_allowed' ; $ ( '#areaDialog' ). showModal (); });
$ ( '#newSite' ). addEventListener ( 'click' , () => notify ( '新建站点需先填写时区、场景包、配额和管理员,本原型只枚举入口。' ));
$ ( '#saveAreaPolicy' ). addEventListener ( 'click' , () => { if ( ! $ ( '#areaReason' ). value . trim ()) { notify ( '请填写变更原因。' ); $ ( '#areaReason' ). focus (); return ; } if ( $ ( '#areaName' ). value === '北围墙' && $ ( '#areaPolicy' ). value === 'non_imaging_only' ) { $ ( '#areaDialog' ). close (); $ ( '#policyConflictDialog' ). showModal (); return ; } $ ( '#areaDialog' ). close (); notify ( 'Area 策略新版本已保存,等待 Sense 拉取投影。' ); });
$ ( '#confirmPolicyConflict' ). addEventListener ( 'click' , () => { const action = $ ( 'input[name="conflictAction"]:checked' ). value ; notify ( action === 'move' ? '已创建 4 台设备迁移任务;完成前不会发布策略。' : '已取消策略变更,v42 和已有设备保持不变。' ); });
2026-08-04 11:08:41 +08:00
2026-08-04 09:04:52 +08:00
const ruleDialog = $ ( '#ruleDialog' );
function openRuleEditor () { ruleDialog . showModal (); }
$ ( '#openRuleDialog' ). addEventListener ( 'click' , openRuleEditor );
$ ( '#continueRuleDraft' ). addEventListener ( 'click' , openRuleEditor );
2026-08-04 11:49:18 +08:00
$$ ( '[data-edit-rule]' ). forEach ( button => button . addEventListener ( 'click' , openRuleEditor ));
function syncSenseZoneLink () { $ ( '#editRuleZone' ). href = `../sense/index.html?view=device-detail&camera= ${ encodeURIComponent ( $ ( '#ruleCamera' ). value ) } &zone= ${ encodeURIComponent ( $ ( '#ruleZone' ). value ) } &return_to=bell-rule` ; }
$ ( '#ruleCamera' ). addEventListener ( 'change' , () => { syncSenseZoneLink (); notify ( '已切换摄像头,请确认检测区域版本与画面仍匹配。' ); });
$ ( '#ruleZone' ). addEventListener ( 'change' , syncSenseZoneLink );
$ ( '#simulateRuleConflict' ). addEventListener ( 'click' , () => { $ ( '#ruleConflict' ). hidden = false ; $ ( '#saveRuleDraft' ). disabled = true ; notify ( '已读取服务端 v8;当前 v7 草稿被阻止覆盖。' ); });
$ ( '#saveRuleDraft' ). addEventListener ( 'click' , event => { if ( ! $ ( '#ruleConflict' ). hidden ) { event . preventDefault (); notify ( '版本冲突未解决,不能覆盖 v8。' ); return ; } notify ( $ ( '#startDryRun' ). checked ? '规则草稿已保存并进入试运行,不会触发正式预警。' : '规则草稿已保存,尚未开始试运行。' ); });
$ ( '#publishTrialRule' ). addEventListener ( 'click' , () => { if ( confirm ( '正式发布后将影响 3 个点位并启动预警链。确认发布当前试运行版本?' )) { $ ( '#trialBadge' ). className = 'badge success' ; $ ( '#trialBadge' ). textContent = '正式生效' ; notify ( '规则版本已正式发布,变更已记录审计并可回滚。' ); } });
$ ( '#openRollback' ). addEventListener ( 'click' , () => $ ( '#rollbackDialog' ). showModal ());
$ ( '#confirmRollback' ). addEventListener ( 'click' , () => { $ ( '#rollbackDialog' ). close (); $ ( '#publishedRuleBadge' ). textContent = '正式生效 · v8(回滚)' ; notify ( '已创建并发布 v8,其配置等价于 v6;v6、v7 历史保持不变。' ); });
let remain = 102 ;
setInterval (() => { if ( ackOwner || remain <= 0 ) return ; remain -= 1 ; const minutes = String ( Math . floor ( remain / 60 )). padStart ( 2 , '0' ); const seconds = String ( remain % 60 ). padStart ( 2 , '0' ); $ ( '#countdown' ). textContent = ` ${ minutes } : ${ seconds } ` ; }, 1000 );
renderAlerts ();
renderRelatedEvents ( alerts [ 0 ]);
renderEventPage ();
renderAuditPage ();
2026-08-04 09:04:52 +08:00
const routeParams = new URLSearchParams ( location . search );
if ( routeParams . get ( 'view' )) switchView ( routeParams . get ( 'view' ));
2026-08-04 14:14:06 +08:00
if ( routeParams . get ( 'view' ) === 'chains' && routeParams . get ( 'section' )) switchChainSection ( routeParams . get ( 'section' ));
2026-08-04 11:49:18 +08:00
if ( routeParams . get ( 'view' ) === 'audit' && ( routeParams . has ( 'device' ) || routeParams . has ( 'site' ))) {
const tenant = routeParams . get ( 'tenant' ) || '当前租户' ;
const site = routeParams . get ( 'site' ) || '当前站点' ;
const device = routeParams . get ( 'device' ) || '全部设备' ;
$ ( '#auditContext' ). hidden = false ;
$ ( '#auditTenant' ). textContent = tenant ;
$ ( '#auditSite' ). textContent = site ;
$ ( '#auditDevice' ). textContent = device ;
$ ( '#auditSearch' ). value = device ;
auditRecords . unshift ({ time : '10:47:36' , role : '站点管理员' , action : 'PAUSE_INFERENCE' , object : device , result : '成功' , badge : 'success' , source : 'Sense outbox · relay 已送达' });
renderAuditPage ();
$ ( '#auditReturn' ). href = `../sense/index.html?view=device-detail&device= ${ encodeURIComponent ( device ) } &return_to=bell-audit` ;
notify ( '已安全应用来自 Sense 的租户、站点和设备审计筛选。' );
2026-08-04 11:08:41 +08:00
}
2026-08-04 11:49:18 +08:00
if ( routeParams . has ( 'event' )) {
switchView ( 'events' );
showEventDetail ( routeParams . get ( 'event' ));
}
if ( routeParams . has ( 'alert' )) openAlert ( routeParams . get ( 'alert' ));
2026-08-04 09:04:52 +08:00
if ( routeParams . has ( 'camera' )) {
2026-08-04 11:49:18 +08:00
switchView ( 'rules' );
$ ( '#ruleContext' ). hidden = false ;
const camera = routeParams . get ( 'camera' );
if ([... $ ( '#ruleCamera' ). options ]. some ( option => option . value === camera )) $ ( '#ruleCamera' ). value = camera ;
const zone = routeParams . get ( 'zone' );
if ( zone && [... $ ( '#ruleZone' ). options ]. some ( option => option . value === zone )) $ ( '#ruleZone' ). value = zone ;
$ ( '#ruleContextCamera' ). textContent = $ ( '#ruleCamera' ). selectedOptions [ 0 ]. textContent ;
$ ( '#ruleContextZone' ). textContent = zone || $ ( '#ruleZone' ). value ;
2026-08-04 10:05:56 +08:00
syncSenseZoneLink ();
2026-08-04 09:04:52 +08:00
notify ( '已保留摄像头与检测区域上下文,可继续配置规则。' );
}
2026-08-04 00:23:55 +08:00
</ script >
</ body >
</ html >