@@ -79,6 +79,7 @@
. 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 ; }
@@ -132,10 +133,18 @@
. 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 ; }
@@ -149,6 +158,8 @@
. 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 ; }
@@ -167,6 +178,8 @@
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 ; }
@@ -190,6 +203,9 @@
. 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-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 ; }
@@ -206,6 +222,7 @@
. 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 ) ; }
@@ -216,7 +233,7 @@
. 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 { width : min ( 760 px , calc ( 100 % - 28 px ) ) ; }
# ruleDialog , # eventDialog , # handoverDialog , # rollbackDialog { 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 ; }
@@ -232,6 +249,7 @@
. 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 ; }
@@ -249,6 +267,7 @@
. 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 ; }
@@ -260,6 +279,7 @@
. site-grid { grid-template-columns : 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 ) ; }
@@ -302,13 +322,16 @@
< 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" > Operations < / 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 >
@@ -319,7 +342,8 @@
< 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 > < / select >
< 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 >
@@ -329,7 +353,7 @@
< 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 = "muteButton" > 限时静默< / button > < / div > < / div >
< 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 >
@@ -350,18 +374,19 @@
< 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 >
< 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 > < span > 23:14:10 · 已送达 < / span > < / div >
< div class = "step current" > < i class = "step-dot" > < / i > < strong > 值班员短信< / strong > < span > 23:14:13 · 已送达,未确认 < / span > < / div >
< div class = "step" > < i class = "step-dot" > < / i > < strong > 校级负责人语音< / strong > < span > 预计 23:16:10 · 尚未发送 < / span > < / div >
< 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 >
@@ -375,18 +400,14 @@
< 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 >
< tr > < td class = "mono" > EVT-01J8…7A2< / td > < td > 23:14:08< / td > < td > 北围墙 01< / td > < td > 非授权人员进入< / td > < td > < span class = "badge danger" > 已确认< / span > < / td > < td > Alert #1842< / td > < td > 处置中< / td > < td > < span class = "badge success" > 可用< / span > < / td > < / tr >
< tr > < td class = "mono" > EVT-01J8…69P< / td > < td > 22:48:31< / td > < td > 宿舍东通道< / td > < td > 区域聚集< / td > < td > < span class = "badge info" > 已记录< / span > < / td > < td > 未触发< / td > < td > 试运行样本< / td > < td > < span class = "badge success" > 可用< / span > < / td > < / tr >
< tr > < td class = "mono" > EVT-01J8…52C< / td > < td > 22:19:04< / td > < td > 实验楼入口< / td > < td > 危险区域闯入< / td > < td > < span class = "badge danger" > 已确认< / span > < / td > < td > Alert #1839< / td > < td > 误报< / td > < td > < span class = "badge warning" > 重试中< / span > < / td > < / tr >
< / tbody > < / table > < / div > < / article >
< 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" > 正式生效< / 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 > < 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 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 >
@@ -404,6 +425,7 @@
< 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" >
@@ -416,9 +438,7 @@
< 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" >
< tr > < td > 23:14:18< / td > < td > 站点值班员< / td > < td > ACK_ALERT< / td > < td class = "mono" > ALT-1842< / td > < td > < span class = "badge success" > 成功< / span > < / td > < td > 值班室 Web< / td > < / tr > < tr > < td > 22:52:06< / td > < td > 站点管理员< / td > < td > PUBLISH_RULE< / td > < td class = "mono" > RULE-007< / td > < td > < span class = "badge success" > 成功< / span > < / td > < td > 管理端< / td > < / tr > < tr > < td > 22:20:31< / td > < td > 只读用户< / td > < td > VIEW_EVIDENCE< / td > < td class = "mono" > EVT-01J8…52C< / td > < td > < span class = "badge danger" > 拒绝< / span > < / td > < td > 响应式 H5< / td > < / tr >
< / tbody > < / table > < / div > < / article >
< 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 >
@@ -435,8 +455,9 @@
< 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 class = "btn" value = "cancel" > 取消< / button > < button class = "btn btn-primary" id = "saveRuleDraft" value = "default" > 保存并开始试运行< / button > < / div > < / form > < / dialog >
< / 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 >
@@ -445,44 +466,315 @@
< 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" > 交接期间升级链继续计时,不暂停、不重置。接班人确认前,当前责任关系保持不变。< / 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 >
< 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 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 : '短信已送达' } ,
{ id : 'ALT-1841' , severity : 'S1 高危' , badge : 'danger' , title : '方向越线' , site : '宿舍东通道' , time : '23:12:46' , age : '3 分钟' , rule : '夜间越线 A → B' , delivery : '声光已触发' } ,
{ id : 'ALT-1839' , severity : 'S2 重要' , badge : 'warning' , title : '区域聚集' , site : '操场西北角' , time : '23:08:21' , age : '7 分钟' , rule : '人数 ≥ 6 持续 20 秒' , delivery : 'Web 已看到' } ,
{ id : 'ALT-1836' , severity : 'S2 重要' , badge : 'warning' , title : '危险区域滞留' , site : '配电房门口' , time : '22:57:03' , age : '18 分钟' , rule : '区域内停留 ≥ 10 秒' , delivery : '等待短信回执' }
{ 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 = 0 ) { alertList . innerHTML = alerts . map ( ( a , i ) => ` <button class="alert-card ${ i === selected ? 'selected' : '' } " data-index=" ${ i } "><div class="alert-card-top"><span class="badge ${ a . badge } "><i class="severity-dot"></i> ${ a . severity } </span><span class="muted"> ${ a . age } </span></div><h3> ${ a . title } </h3><p> ${ a . site } · ${ a . time } </p><div class="alert-card-foot"><span> ${ a . delivery } </span><span class="mono"> ${ a . id } </span></div></button> ` ) . join ( '' ) ; $$ ( '.alert-card' , alertList ) . forEach ( b => b . addEventListener ( 'click' , ( ) => selectAlert ( Number ( b . dataset . index ) ) ) ) ; }
function selectAlert ( index ) { renderAlerts ( index ) ; const a = alerts [ index ] ; $ ( '# selectedSeverity' ) . className = ` badge ${ a . badge } ` ; $ ( '#selectedSeverity' ) . textContent = a . severity ; $ ( '#selectedTitle' ) . textContent = a . title ; $ ( '#selectedMeta' ) . textContent = ` ${ a . site } · ${ a . time } · ${ a . id } ` ; $ ( '#factRule' ) . textContent = a . rule ; $ ( '#ackState' ) . className = 'badge warning' ; $ ( '#ackState' ) . textContent = '等待 ack' ; $ ( '#ackButton' ) . disabled = false ; $ ( '#ackButton' ) . innerHTML = '<svg class="ic on"><use href="#i-check"/></svg>确认接手' ; $ ( '#ackHelp' ) . textContent = '一次点击确认;并发确认时会显示实际接手人,不覆盖对方结果。' ; }
renderAlerts ( ) ;
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="m ono ">${ 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 switchView ( name ) { $$ ( '.view' ) . forEach ( v => v . classList . toggle ( 'active' , v . dataset . page === name ) ) ; $$ ( '[data-view]' ) . forEach ( b => { const on = b . dataset . view === name ; b . toggleAttribute ( 'aria-current' , on ) ; b . classList . toggle ( 'active' , on ) ; } ) ; $ ( '#stateSelect' ) . value = 'normal' ; renderState ( 'normal' ) ; $ ( '#main-content' ) . focus ( { preventScroll : true } ) ; }
$$ ( '[data-view]' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => switchView ( b . dataset . view ) ) ) ;
$ $( '.queue-tabs button' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => { $$ ( '.queue-tabs button' ) . forEach ( x => { x . classList . toggle ( 'active' , x === b ) ; x . setAttribute ( 'aria-pressed' , x === b ) } ) ; notify ( ` 队列已切换到“ ${ b . textContent } ” ` ) ; } ) ) ;
$ $( '. evidence-tabs button' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => { $$ ( '.evidence-tabs button' ) . forEach ( x => { x . classList . toggle ( 'active' , x === b ) ; x . setAttribute ( 'aria-pressed' , x === b ) } ) ; notify ( ` 正在演示“ ${ b . textContent } ”标签结构 ` ) ; } ) ) ;
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 ( ) ;
}
function renderState ( state ) { const layer = $ ( '#stateLayer' ) , views = $$ ( '.view' ) ; layer . classList . toggle ( 'show' , state !== 'normal' ) ; views . forEach ( v => v . 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" onclick="document.querySelector('#stateSelect').value='normal';renderState('normal');notify('证据重试任务已加入后台队列')">后台重试证据</button></div> ` ,
empty : ` <div><svg class="icon"><use href="#i-check"/></svg><h2>当前没有待处置预警</h2><p>系统在线,新的 Alert 会实时进入队列。历史事件仍可从事件中心查询。</p><button class="btn" onclick="switchView('events')">查看事件中心</button></div> ` ,
denied : ` <div><svg class="icon" style="color:var(--danger)"><use href="#i-lock"/></svg><h2>没有查看此站点证据的权限</h2><p>当前账号只能查看被授权站点。系统不会泄露事件是否存在、流地址或设备信息。</p><button class="btn" onclick="document.querySelector('#stateSelect').value='normal';renderState('normal')">返回可访问范围</button></div> ` } ; layer . innerHTML = states [ state ] ; }
$ ( '#stateSelect' ) . addEventListener ( 'change' , e => renderState ( e . target . value ) ) ;
$ ( '#themeToggle' ) . addEventListener ( 'click' , ( ) => { const h = document . documentElement ; h . dataset . theme = h . dataset . theme === 'dark' ? 'light' : 'dark' ; notify ( ` 已切换为 ${ h . dataset . theme === 'dark' ? '深色' : '浅色' } 主题 ` ) ; } ) ;
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 ;
}
$ ( '#ackButton' ) . addEventListener ( 'click' , async e => { const b = e . currentTarget ; b . disabled = true ; b . textContent = '确认中…' ; await new Promise ( r => setTimeout ( r , 650 ) ) ; b . innerHTML = '<svg class="icon"><use href="#i-check"/></svg>已由当前值班员接手' ; $ ( '#ackState' ) . className = 'badge success' ; $ ( '#ackState' ) . textContent = '已 ack' ; $ ( '#ackHelp' ) . textContent = '23:15:02 · 当前值班员 · 服务端确认成功' ; notify ( '确认成功,升级计时已停止并记录审计。' ) ; } ) ;
$ ( '#completeAlert' ) . addEventListener ( 'click' , ( ) => { if ( ! $ ( '#outcome' ) . value ) { notify ( '请先选择处置结果。' ) ; $ ( '#outcome' ) . focus ( ) ; return ; } if ( $ ( '#ackState' ) . textContent !== '已 ack' ) { notify ( '请先确认接手,再完成处置。' ) ; $ ( '#ackButton' ) . focus ( ) ; return ; } notify ( '处置结果已追加,原始事件事实未被修改。' ) ; } ) ;
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' , e => { if ( ! $ ( '#falseReason' ) . value ) { e . preventDefault ( ) ; notify ( '请选择误报原因。' ) ; $ ( '#falseReason' ) . focus ( ) ; return ; } notify ( '已标记误报并进入复核队列,原始事件保持不变。' ) ; } ) ;
$ ( '#confirmFalse' ) . addEventListener ( 'click' , event => { if ( ! $ ( '#falseReason' ) . value ) { event . preventDefault ( ) ; notify ( '请选择误报原因。' ) ; $ ( '#falseReason' ) . focus ( ) ; return ; } notify ( '已标记误报并进入复核队列,原始事件保持不变。' ) ; } ) ;
$ ( '#muteButton' ) . addEventListener ( 'click' , ( ) => $ ( '#muteDialog' ) . showModal ( ) ) ;
$ ( '#confirmMute' ) . addEventListener ( 'click' , e => { if ( ! $ ( '#muteCause' ) . value . trim ( ) ) { e . preventDefault ( ) ; notify ( '请填写静默原因。' ) ; $ ( '#muteCause' ) . focus ( ) ; return ; } notify ( '已创建限时静默,到期将自动恢复并通知值班组。' ) ; } ) ;
$ ( '#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 的升级链继续运行。' ) ; } ) ;
$ ( '#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 ( ) ; } ) ;
@@ -498,25 +790,50 @@
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 ) ;
$ ( '#saveRuleDraf t' ) . addEventListener ( 'click' , ( ) => notify ( $ ( '#startDryRun' ) . checked ? '规则草稿已保存并进入试运行,不会触发正式预警。' : '规则草稿已保存,尚未开始试运行。' ) ) ;
$ ( '#simulateRuleConflic t' ) . 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 ( $ ( '#ackState' ) . textContent === '已 ack' || remain <= 0 ) return ; remain -- ; const m = String ( Math . floor ( remain / 60 ) ) . padStart ( 2 , '0' ) , s = String ( remain % 60 ) . padStart ( 2 , '0' ) ; $ ( '#countdown' ) . textContent = ` ${ m } : ${ s } ` ; } , 1000 ) ;
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' ) === 'audit' && ( routeParams . has ( 'device' ) || routeParams . has ( 'site' ) ) ) {
const tenant = routeParams . get ( 'tenant' ) || '当前租户' , site = routeParams . get ( 'site' ) || '当前站点' , device = routeParams . get ( 'device' ) || '全部设备' ;
$ ( '#auditContext' ) . hidden = false ; $ ( '#auditTenant' ) . textContent = tenant ; $ ( '#auditSite' ) . textContent = site ; $ ( '#auditDevice' ) . textContent = device ; $ ( '#auditSearch' ) . value = device ;
$ ( '#auditRows' ) . insertAdjacentHTML ( 'afterbegin' , ` <tr><td>10:47:36</td><td>站点管理员</td><td>PAUSE_INFERENCE</td><td class="mono"> ${ device } </td><td><span class="badge success">成功</span></td><td>Sense outbox · relay 已送达</td></tr> ` ) ;
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 的租户、站点和设备审计筛选。' ) ;
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 ;
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 ( '已保留摄像头与检测区域上下文,可继续配置规则。' ) ;
}