Files
yovision/docs/design/bell/index.html
QiuSW 8115f584dd
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
docs(design): unify Bell roster and escalation targets
2026-08-04 14:14:06 +08:00

1000 lines
112 KiB
HTML
Raw Permalink Blame History

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