@@ -0,0 +1,999 @@
<!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 ; }
[ hidden ] { display : none !important ; }
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 ; }
. 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 ; }
. 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 ; }
. 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 ; }
. 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 ; }
. 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 ; }
. 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 ; }
. 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 ; }
. 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 ; }
. delivery-facts { margin-top : 4 px ; display : grid ; gap : 2 px ; }
. delivery-facts span { display : block ; }
. 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 ) ; }
. 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 ; }
. 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 ) ; }
. 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 ) ; }
. 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 ) ; }
. 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 ; }
. 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 ; }
. 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 ; }
. 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 ) ; }
. 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 ; }
. 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 ; }
. 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 ) ; }
. 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 ) ; }
# ruleDialog , # eventDialog , # handoverDialog , # rollbackDialog , # escalationDialog , # scheduleDialog , # substituteDialog , # contactDialog { width : min ( 760 px , calc ( 100 % - 28 px ) ) ; }
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 ; }
. 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 ) ; }
. 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 ; }
. mobile-manage { display : none ; }
@ 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 ; }
. 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 ; }
. mobile-manage { display : inline-flex ; min-width : 44 px ; }
. content { padding : 12 px ; }
. btn , . select , . field , . textarea { min-height : 44 px ; }
. icon-btn { min-width : 44 px ; }
. 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 ) ) ; }
. site-grid { grid-template-columns : 1 fr ; }
. roster-week { grid-template-columns : repeat ( 2 , minmax ( 145 px , 1 fr ) ) ; }
. rule-grid { grid-template-columns : 1 fr 1 fr ; }
. fact-grid { grid-template-columns : repeat ( 2 , 1 fr ) ; }
. pager { align-items : flex-start ; flex-direction : column ; }
. 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 ; }
. chain-subnav { grid-template-columns : 1 fr ; }
. contact-summary { grid-template-columns : 1 fr ; }
. roster-week { grid-template-columns : 1 fr ; }
. 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 >
< div class = "nav-label" > 值班与业务< / div >
< 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 >
< / nav >
< div class = "nav-label" > 管理< / div >
< nav class = "nav" >
< button data-view = "sites" > < svg class = "icon" > < use href = "#i-rule" / > < / svg > < span > 站点与 Area< / span > < / button >
< 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" >
< div class = "site" > < strong > 青藤教育集团 · 青藤寄宿学校< / strong > < span > 当前账号:林工 · 租户管理员 · Asia/Shanghai< / span > < / div >
< div class = "top-actions" >
< 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 >
< 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" >
< 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 >
< div class = "duty-grid" >
< section class = "card queue" aria-label = "待处置预警队列" >
< div class = "card-head" > < h2 > 待处置预警< / h2 > < span class = "badge danger" > 6 个未 ack< / span > < / div >
< 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 >
< 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 >
< 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 >
< 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 >
< div class = "relation-panel" > < h3 > 关联事件 · < span id = "relatedEventCount" > 2< / span > < / h3 > < div class = "relation-list" id = "relatedEvents" > < / div > < / div >
< 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" >
< 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 >
< 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 >
< 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 >
< / 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 >
< 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 >
< / section >
< section class = "view" data-page = "rules" >
< 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 >
< 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" >
< 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 >
< 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 >
< 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 >
< / div >
< / section >
< section class = "view" data-page = "chains" >
< 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 >
< / 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 >
< 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 >
< / section >
< 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 >
< section class = "view" data-page = "audit" >
< 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 >
< div class = "page-head" > < div > < h1 > 审计日志< / h1 > < p > 查询、ack、规则发布和证据下载均可追溯;不记录密码与可复用连接串。< / p > < / div > < div class = "page-actions" > < button class = "btn" > 导出审计记录< / button > < / div > < / div >
< 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 >
< / 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 >
< 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 >
< 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 >
< 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 >
< div class = "inline-alert danger form-field full" id = "ruleConflict" role = "alert" hidden > < strong > 规则已由周管理员发布为 v8。< / strong > < br > 当前草稿基于 v7,不能覆盖。请刷新到 v8 后重新合并,或另存为新草稿。< / div >
< p class = "workflow-note" > 产品流程:先确认摄像头和检测区域,再配置业务条件;试运行样本达到现场评审要求后,才允许正式发布。< / p >
< / 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 >
< 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 >
< 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 >
< 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 >
< 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 >
< 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 >
< 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 >
< div class = "toast" id = "toast" role = "status" aria-live = "polite" > < / div >
< script >
const $ = ( selector , root = document ) => root . querySelector ( selector ) ;
const $$ = ( selector , root = document ) => [ ... root . querySelectorAll ( selector ) ] ;
const toast = $ ( '#toast' ) ;
let toastTimer ;
function notify ( message ) {
toast . textContent = message ;
toast . classList . add ( 'show' ) ;
clearTimeout ( toastTimer ) ;
toastTimer = setTimeout ( ( ) => toast . classList . remove ( 'show' ) , 3800 ) ;
}
const alerts = [
{ 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 : '单事件触发' } ] }
] ;
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 ;
const alertList = $ ( '#alertList' ) ;
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 } 。 ` ) ;
}
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 ( ) ;
}
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 ;
}
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 ;
}
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 的升级链继续运行。' ) ; } ) ;
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 ? '联系人主数据已更新;既有投递快照未改变。' : '联系人已保存,短信/语音通道进入验证流程;尚未加入任何班次。' ) ;
} ) ;
$ ( '#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 和已有设备保持不变。' ) ; } ) ;
const ruleDialog = $ ( '#ruleDialog' ) ;
function openRuleEditor ( ) { ruleDialog . showModal ( ) ; }
$ ( '#openRuleDialog' ) . addEventListener ( 'click' , openRuleEditor ) ;
$ ( '#continueRuleDraft' ) . addEventListener ( 'click' , openRuleEditor ) ;
$$ ( '[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 ( ) ;
const routeParams = new URLSearchParams ( location . search ) ;
if ( routeParams . get ( 'view' ) ) switchView ( routeParams . get ( 'view' ) ) ;
if ( routeParams . get ( 'view' ) === 'chains' && routeParams . get ( 'section' ) ) switchChainSection ( routeParams . get ( 'section' ) ) ;
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 的租户、站点和设备审计筛选。' ) ;
}
if ( routeParams . has ( 'event' ) ) {
switchView ( 'events' ) ;
showEventDetail ( routeParams . get ( 'event' ) ) ;
}
if ( routeParams . has ( 'alert' ) ) openAlert ( routeParams . get ( 'alert' ) ) ;
if ( routeParams . has ( 'camera' ) ) {
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 ;
syncSenseZoneLink ( ) ;
notify ( '已保留摄像头与检测区域上下文,可继续配置规则。' ) ;
}
< / script >
< / body >
< / html >