@@ -206,11 +206,35 @@
. 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 ; }
@@ -233,7 +257,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 , # eventDialog , # handoverDialog , # rollbackDialog { width : min ( 760 px , calc ( 100 % - 28 px ) ) ; }
# 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 ; }
@@ -277,6 +301,7 @@
. 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 ; }
@@ -287,6 +312,9 @@
@ 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 ; }
@@ -414,12 +442,37 @@
< / section >
< section class = "view" data-page = "chains" >
< div class = "page-head" > < div > < h1 > 升级链< / h1 > < p > 生产前至少两条独立路径;无回执不能当作成功 。< / p > < / div > < div class = "page-actions" > < button class = "btn btn-primary" > 编辑升级链 < / button > < / div > < / div >
< div class = "grid chain-grid " > < article class = "card" > < div class = "card-head" > < h2 > 校园高危事件 · 默认链 < / h2 > < span class = "badge success" > 已发布 v4 < / span > < / div >
< div class = "chain-row" > < div class = "chain-num" > 1< / div > < div > < strong > 值班室 Web + 本地声光< / strong > < small > 立即 · 站点局域网内< / small > < / div > < span class = "badge success" > 主路径< / span > < / div >
< div class = "chain-row" > < div class = "chain-num" > 2 < / div > < div > < strong > 当班值班员短信 < / strong > < small > 立即 · 送达后等待 2 分钟 < / small > < / div > < span class = "badge info" > 独立路径 < / span > < / div >
< div class = "chain-row " > < div class = "chain-num " > 3 < / div > < div > < strong > 校级负责人语音 < / strong > < small > 未 ack 时升级 · 等待 3 分钟 < / small > < / div > < span class = "badge warning" > 兜底 < / span > < / 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 >
< 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" >
@@ -471,9 +524,17 @@
< 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 = "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 >
@@ -773,6 +834,101 @@
$ ( '#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 ( ) } 。 ` ) ; } ) ) ;
@@ -806,6 +962,7 @@
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' ) || '当前站点' ;