Files
yovision/docs/design/sense/index.html
T
QiuSW f43a4efa80
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
docs(design): add Sense and Bell prototypes
2026-08-04 00:23:55 +08:00

514 lines
49 KiB
HTML
Raw 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 Sense · 设备与视频接入原型</title>
<style>
:root {
--bg: #08111f;
--surface: #0d1929;
--surface-2: #111f31;
--surface-3: #17263a;
--text: #f2f7fb;
--muted: #9fb0c5;
--subtle: #71849c;
--line: #27384d;
--primary: #38bdf8;
--primary-strong: #0ea5e9;
--primary-soft: rgba(56, 189, 248, .14);
--success: #34d399;
--warning: #fbbf24;
--danger: #fb7185;
--violet: #a78bfa;
--shadow: 0 16px 40px rgba(0, 0, 0, .22);
--radius: 14px;
--sidebar: 224px;
--banner: 28px;
}
html[data-theme="light"] {
--bg: #eef4f8;
--surface: #ffffff;
--surface-2: #f7fafc;
--surface-3: #eaf1f6;
--text: #112033;
--muted: #50647b;
--subtle: #718096;
--line: #cfdae5;
--primary-soft: rgba(2, 132, 199, .11);
--shadow: 0 14px 34px rgba(38, 64, 92, .12);
}
* { box-sizing: border-box; }
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(56, 189, 248, .45);
outline-offset: 2px;
}
.prototype-banner {
position: fixed; inset: 0 0 auto 0; z-index: 1000;
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); height: calc(100dvh - var(--banner));
border-right: 1px solid var(--line); background: var(--surface); padding: 18px 12px;
display: flex; flex-direction: column; z-index: 30;
}
.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, #38bdf8, #6366f1); color: white; font-weight: 900; box-shadow: 0 8px 20px rgba(14, 165, 233, .22); }
.brand strong { display: block; font-size: 16px; letter-spacing: .01em; }
.brand small { color: var(--muted); }
.nav-label { margin: 8px 10px 6px; color: var(--subtle); font-size: 11px; font-weight: 700; 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: background .2s ease, color .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; }
.sidebar-foot { margin-top: auto; padding: 12px 10px 2px; border-top: 1px solid var(--line); color: var(--muted); }
.online-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 4px rgba(52, 211, 153, .12); }
.main { min-width: 0; }
.topbar {
position: sticky; top: var(--banner); z-index: 20; min-height: 64px; padding: 10px 22px;
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-select { display: flex; align-items: center; gap: 8px; min-width: 250px; }
.site-select strong { font-size: 15px; }
.site-select span { color: var(--muted); font-size: 12px; }
.top-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.select, .field, .textarea {
width: 100%; min-height: 40px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); padding: 8px 11px;
}
.textarea { min-height: 74px; resize: vertical; }
.top-actions .select { width: auto; min-width: 126px; }
.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: 700; transition: border-color .2s ease, background .2s ease, opacity .2s ease;
}
.btn:hover { border-color: var(--primary); }
.btn-primary { background: var(--primary-strong); border-color: var(--primary-strong); color: #fff; }
.btn-danger { color: #ffd8df; border-color: rgba(251, 113, 133, .45); background: rgba(251, 113, 133, .1); }
.btn-quiet { border-color: transparent; background: transparent; color: var(--muted); }
.icon-btn { width: 42px; padding: 0; }
.content { padding: 22px; }
.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 { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.page-head h1 { margin: 0 0 4px; font-size: 24px; line-height: 1.25; }
.page-head p { margin: 0; color: var(--muted); }
.page-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.grid { display: grid; gap: 14px; }
.stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
.stat { padding: 16px; min-height: 116px; display: flex; flex-direction: column; justify-content: space-between; }
.stat-top { display: flex; align-items: center; justify-content: space-between; color: var(--muted); }
.stat strong { font-size: 27px; font-variant-numeric: tabular-nums; }
.trend { font-size: 12px; color: var(--success); }
.split { grid-template-columns: minmax(0, 1.6fr) minmax(300px, .8fr); margin-top: 14px; }
.card-head { padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--line); }
.card-head h2, .card-head h3 { margin: 0; font-size: 15px; }
.card-body { padding: 16px; }
.health-list { display: grid; gap: 12px; }
.health-row { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; }
.health-row span { color: var(--muted); }
.health-row strong { font-variant-numeric: tabular-nums; }
.meter { grid-column: 1/-1; height: 7px; border-radius: 99px; background: var(--surface-3); overflow: hidden; }
.meter i { display: block; height: 100%; border-radius: inherit; background: var(--primary); }
.badge { display: inline-flex; align-items: center; gap: 5px; min-height: 24px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); font-size: 12px; white-space: nowrap; }
.badge.success { color: #74efc1; border-color: rgba(52, 211, 153, .35); background: rgba(52, 211, 153, .1); }
.badge.warning { color: #ffdc74; border-color: rgba(251, 191, 36, .35); background: rgba(251, 191, 36, .1); }
.badge.danger { color: #ff9aae; border-color: rgba(251, 113, 133, .4); background: rgba(251, 113, 133, .1); }
.badge.info { color: #8edaff; border-color: rgba(56, 189, 248, .35); background: var(--primary-soft); }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.search { position: relative; min-width: 220px; }
.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: 820px; }
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; }
th { color: var(--muted); background: var(--surface-2); font-size: 12px; font-weight: 700; }
td { font-variant-numeric: tabular-nums; }
tr:last-child td { border-bottom: 0; }
tr:hover td { background: color-mix(in srgb, var(--primary-soft) 45%, transparent); }
.device-name { display: flex; align-items: center; gap: 10px; }
.camera-icon { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--surface-3); color: var(--primary); }
.device-name small { display: block; color: var(--muted); }
.camera-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.camera-card { overflow: hidden; }
.camera-stage { aspect-ratio: 16/9; position: relative; background: #020914; overflow: hidden; }
.camera-stage::before { content: ""; position: absolute; inset: 0; background: linear-gradient(160deg, transparent 45%, rgba(56,189,248,.08)), repeating-linear-gradient(0deg, rgba(255,255,255,.025) 0 1px, transparent 1px 4px); }
.scene { position: absolute; inset: 0; width: 100%; height: 100%; }
.camera-meta { padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.camera-meta small { color: var(--muted); }
.zone-layout { display: grid; grid-template-columns: 230px minmax(420px, 1fr) 310px; gap: 12px; min-height: calc(100dvh - 162px); }
.zone-camera-list, .zone-settings, .zone-workspace { min-width: 0; }
.compact-list { display: grid; gap: 6px; padding: 10px; }
.compact-list button { width: 100%; min-height: 54px; border: 1px solid transparent; border-radius: 9px; background: transparent; color: var(--text); display: flex; align-items: center; gap: 10px; padding: 8px; text-align: left; }
.compact-list button:hover, .compact-list button.selected { background: var(--primary-soft); border-color: rgba(56,189,248,.32); }
.compact-list small { display: block; color: var(--muted); }
.mini-thumb { width: 54px; height: 34px; border-radius: 6px; background: linear-gradient(145deg, #24364c, #0a101a); position: relative; overflow: hidden; }
.mini-thumb::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: 8px; height: 1px; background: var(--primary); transform: rotate(-12deg); }
.editor-tools { padding: 10px; display: flex; gap: 6px; align-items: center; border-bottom: 1px solid var(--line); overflow-x: auto; }
.tool-btn { min-width: 42px; min-height: 40px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-2); color: var(--muted); display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap; }
.tool-btn.active { background: var(--primary-soft); border-color: var(--primary); color: var(--text); }
.tool-divider { width: 1px; align-self: stretch; background: var(--line); margin: 0 3px; }
.editor-stage { position: relative; aspect-ratio: 16/9; min-height: 340px; background: #030a12; overflow: hidden; }
.editor-stage svg { width: 100%; height: 100%; display: block; touch-action: none; }
.editor-hint { position: absolute; left: 12px; bottom: 12px; padding: 7px 10px; border-radius: 8px; background: rgba(3, 10, 18, .78); color: #dceaff; font-size: 12px; pointer-events: none; }
.editor-status { display: flex; justify-content: space-between; gap: 10px; padding: 10px 12px; color: var(--muted); border-top: 1px solid var(--line); font-size: 12px; }
.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; }
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 42px; }
.switch { position: relative; width: 42px; height: 24px; }
.switch input { position: absolute; opacity: 0; }
.switch span { position: absolute; inset: 0; border-radius: 99px; background: var(--surface-3); border: 1px solid var(--line); transition: .2s ease; }
.switch span::after { content: ""; position: absolute; width: 18px; height: 18px; left: 2px; top: 2px; border-radius: 50%; background: var(--muted); transition: transform .2s ease, background .2s ease; }
.switch input:checked + span { background: var(--primary-strong); border-color: var(--primary-strong); }
.switch input:checked + span::after { transform: translateX(18px); background: white; }
.coordinate-box { margin-top: 12px; padding: 12px; border: 1px dashed var(--line); border-radius: 10px; background: var(--surface-2); }
.coordinate-box h3 { margin: 0 0 4px; font-size: 13px; }
.coordinate-box p { margin: 0 0 10px; color: var(--muted); font-size: 12px; }
.coordinate-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 6px; }
.settings-actions { position: sticky; bottom: 0; margin: 14px -16px -16px; padding: 12px 16px; display: flex; gap: 8px; justify-content: flex-end; border-top: 1px solid var(--line); background: var(--surface); }
.drop-zone { padding: 28px; border: 1px dashed var(--line); border-radius: 12px; text-align: center; background: var(--surface-2); }
.drop-zone .icon { width: 28px; height: 28px; color: var(--primary); }
.drop-zone p { margin: 8px 0 12px; color: var(--muted); }
.system-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.system-tile { padding: 16px; }
.system-tile h3 { margin: 0 0 12px; font-size: 14px; }
.system-tile dl { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: 8px; color: var(--muted); }
.system-tile dd { margin: 0; color: var(--text); font-variant-numeric: tabular-nums; }
.state-layer { display: none; min-height: 380px; place-items: center; border: 1px dashed var(--line); border-radius: var(--radius); background: var(--surface); text-align: center; padding: 24px; }
.state-layer.show { display: grid; }
.state-layer .icon { width: 38px; height: 38px; color: var(--primary); }
.state-layer h2 { margin: 12px 0 4px; font-size: 18px; }
.state-layer p { margin: 0 0 14px; color: var(--muted); max-width: 440px; }
.skeleton { width: min(420px, 90%); display: grid; gap: 10px; }
.skeleton i { display: block; height: 14px; 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: 70%; }
@keyframes shimmer { to { background-position: -200% 0; } }
dialog { width: min(680px, calc(100% - 28px)); max-height: 88dvh; border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--surface); color: var(--text); box-shadow: 0 24px 80px rgba(0,0,0,.5); }
dialog::backdrop { background: rgba(0, 8, 18, .64); backdrop-filter: blur(4px); }
.dialog-head, .dialog-foot { padding: 14px 18px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--line); }
.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; }
.toast { position: fixed; right: 20px; bottom: 20px; z-index: 1100; max-width: min(390px, 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; }
@media (max-width: 1180px) {
:root { --sidebar: 78px; }
.brand-copy, .nav span:not(.count), .nav-label, .sidebar-foot { 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; }
.zone-layout { grid-template-columns: 190px minmax(400px, 1fr); }
.zone-settings { grid-column: 1/-1; }
.settings-actions { position: static; margin-bottom: -16px; }
}
@media (max-width: 820px) {
:root { --sidebar: 0px; }
.app { display: block; padding-bottom: 66px; }
.sidebar { display: none; }
.topbar { padding: 8px 12px; }
.site-select { min-width: 0; }
.site-select span, .top-actions .select { display: none; }
.content { padding: 14px 12px; }
.stats, .system-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.split, .zone-layout { grid-template-columns: 1fr; }
.zone-camera-list { order: 2; }
.zone-workspace { order: 1; }
.zone-settings { order: 3; }
.camera-grid { grid-template-columns: 1fr; }
.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: var(--primary); background: var(--primary-soft); }
}
@media (max-width: 560px) {
.stats, .system-grid { grid-template-columns: 1fr; }
.page-head { display: grid; }
.page-actions { width: 100%; }
.page-actions .btn { flex: 1; }
.form-grid { grid-template-columns: 1fr; }
.form-field.full { grid-column: auto; }
.editor-stage { min-height: 230px; }
.coordinate-row { grid-template-columns: 1fr 1fr; }
.coordinate-row .btn { grid-column: 1/-1; }
}
@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-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></symbol>
<symbol id="i-video" viewBox="0 0 24 24"><rect x="3" y="5" width="14" height="14" rx="2"/><path d="m17 10 4-2v8l-4-2z"/></symbol>
<symbol id="i-camera" viewBox="0 0 24 24"><path d="M5 7h3l1.4-2h5.2L16 7h3a2 2 0 0 1 2 2v9H3V9a2 2 0 0 1 2-2Z"/><circle cx="12" cy="12.5" r="3"/></symbol>
<symbol id="i-zone" viewBox="0 0 24 24"><path d="m5 5 14 2-2 12-12-3Z"/><circle cx="5" cy="5" r="1.5"/><circle cx="19" cy="7" r="1.5"/><circle cx="17" cy="19" r="1.5"/><circle cx="5" cy="16" r="1.5"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 16V4m0 0L7 9m5-5 5 5"/><path d="M4 15v5h16v-5"/></symbol>
<symbol id="i-pulse" viewBox="0 0 24 24"><path d="M3 12h4l2-6 4 12 2-6h6"/></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-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></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-chevron" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
<symbol id="i-pointer" viewBox="0 0 24 24"><path d="m5 3 14 8-6 2-3 6Z"/></symbol>
<symbol id="i-line" viewBox="0 0 24 24"><path d="m4 18 16-12"/><circle cx="4" cy="18" r="2"/><circle cx="20" cy="6" r="2"/></symbol>
<symbol id="i-undo" viewBox="0 0 24 24"><path d="M9 7 4 12l5 5"/><path d="M5 12h8a6 6 0 0 1 6 6"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9 7V4h6v3m-9 0 1 14h10l1-14"/></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-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5"/><path d="M18 9a7 7 0 0 0-12-2M6 15a7 7 0 0 0 12 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>
<symbol id="i-wifi" viewBox="0 0 24 24"><path d="M4 9a12 12 0 0 1 16 0M7 13a8 8 0 0 1 10 0m-7 4a3 3 0 0 1 4 0M12 20v.01"/></symbol>
</svg>
<div class="prototype-banner">PROTOTYPE - 仅供枚举交互,非实现依据</div>
<div class="app">
<aside class="sidebar" aria-label="Sense 主导航">
<div class="brand"><div class="brand-mark">Y</div><div class="brand-copy"><strong>YoVision</strong><small>Sense · Edge Console</small></div></div>
<div class="nav-label">Workspace</div>
<nav class="nav">
<button data-view="overview"><svg class="icon"><use href="#i-grid"/></svg><span>运行总览</span></button>
<button data-view="monitor"><svg class="icon"><use href="#i-video"/></svg><span>实时监控</span><span class="count">4</span></button>
<button data-view="devices"><svg class="icon"><use href="#i-camera"/></svg><span>摄像头</span><span class="count">16</span></button>
<button data-view="zones" aria-current="page"><svg class="icon"><use href="#i-zone"/></svg><span>区域规则</span></button>
<button data-view="onboarding"><svg class="icon"><use href="#i-upload"/></svg><span>接入任务</span><span class="count">1</span></button>
<button data-view="system"><svg class="icon"><use href="#i-pulse"/></svg><span>系统状态</span></button>
</nav>
<div class="sidebar-foot"><div class="online-row"><span class="dot"></span><strong>边缘节点在线</strong></div><div class="copy">sense-edge-01<br><span class="mono">v0.1-prototype</span></div></div>
</aside>
<main class="main" id="main-content" tabindex="-1">
<header class="topbar">
<div class="site-select"><div><strong>青藤寄宿学校</strong><span> / 主校区 · 16 / 16 路</span></div><svg class="icon"><use href="#i-chevron"/></svg></div>
<div class="top-actions">
<select class="select" id="stateSelect" aria-label="原型状态演示">
<option value="normal">正常状态</option><option value="loading">加载中</option><option value="empty">空数据</option><option value="error">连接失败</option>
</select>
<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" data-page="overview">
<div class="page-head"><div><h1>运行总览</h1><p>先判断是否影响业务,再进入设备或分片定位原因。</p></div><div class="page-actions"><button class="btn"><svg class="icon"><use href="#i-refresh"/></svg>立即对账</button></div></div>
<div class="grid stats">
<article class="card stat"><div class="stat-top"><span>在线设备</span><svg class="icon"><use href="#i-camera"/></svg></div><strong>15 / 16</strong><span class="trend">93.8% · 1 路重连中</span></article>
<article class="card stat"><div class="stat-top"><span>活跃媒体流</span><svg class="icon"><use href="#i-video"/></svg></div><strong>15</strong><span class="trend">主/子码流均衡</span></article>
<article class="card stat"><div class="stat-top"><span>待收敛项</span><svg class="icon"><use href="#i-refresh"/></svg></div><strong>2</strong><span class="muted">配置 1 · 时间漂移 1</span></article>
<article class="card stat"><div class="stat-top"><span>今日设备告警</span><svg class="icon"><use href="#i-alert"/></svg></div><strong>3</strong><span class="muted">与业务预警分开</span></article>
</div>
<div class="grid split">
<article class="card"><div class="card-head"><h2>分片健康</h2><span class="badge success">全部可用</span></div><div class="card-body health-list">
<div class="health-row"><span>media-01</span><strong>8 / 32</strong><div class="meter"><i style="width:25%"></i></div></div>
<div class="health-row"><span>media-02</span><strong>8 / 32</strong><div class="meter"><i style="width:25%"></i></div></div>
<div class="health-row"><span>推理绑定</span><strong>16 / 16</strong><div class="meter"><i style="width:100%;background:var(--violet)"></i></div></div>
</div></article>
<article class="card"><div class="card-head"><h2>需要处理</h2><span class="badge warning">2 项</span></div><div class="card-body">
<p><strong>北围墙 03</strong><br><span class="muted">断线 02:14 · 正在第 4 次重连</span></p>
<p><strong>实验楼入口 02</strong><br><span class="muted">时间漂移 +3.8s · 建议重新校时</span></p>
</div></article>
</div>
</section>
<section class="view" data-page="monitor">
<div class="page-head"><div><h1>实时监控</h1><p>默认只并发预览 4 路子码流;切页会释放上一页播放器。</p></div><div class="page-actions"><span class="badge info">第 1 / 4 页</span><button class="btn">布局 2 × 2</button></div></div>
<div class="grid camera-grid" id="cameraGrid"></div>
</section>
<section class="view" data-page="devices">
<div class="page-head"><div><h1>摄像头</h1><p>期望态与实际态分开展示;连接地址和凭据始终脱敏。</p></div><div class="page-actions"><button class="btn">扫描设备</button><button class="btn btn-primary" id="openCameraDialog"><svg class="icon"><use href="#i-plus"/></svg>添加摄像头</button></div></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>异常</option><option>重连中</option></select></div><span class="muted">已用 16 / 128</span></div>
<div class="table-wrap"><table><thead><tr><th><input type="checkbox" aria-label="选择全部设备"></th><th>设备</th><th>期望态</th><th>实际态</th><th>最后在线</th><th>时间漂移</th><th>媒体分片</th><th>操作</th></tr></thead><tbody id="deviceRows"></tbody></table></div>
</article>
</section>
<section class="view active" data-page="zones">
<div class="page-head"><div><h1>区域与警戒线</h1><p>在子码流快照上定义规则空间;保存草稿不会立即影响正式策略。</p></div><div class="page-actions"><span class="badge warning" id="draftBadge">草稿未发布</span><button class="btn">版本记录</button><button class="btn btn-primary" id="publishRule">发布规则</button></div></div>
<div class="zone-layout">
<aside class="card zone-camera-list" aria-label="摄像头选择"><div class="card-head"><h2>点位</h2><span class="badge">16</span></div><div style="padding:10px"><div class="search"><svg class="icon"><use href="#i-search"/></svg><input class="field" aria-label="搜索点位" placeholder="搜索点位"></div></div><div class="compact-list">
<button class="selected"><span class="mini-thumb"></span><span><strong>北围墙 01</strong><small>在线 · 2 条规则</small></span></button>
<button><span class="mini-thumb"></span><span><strong>实验楼入口</strong><small>在线 · 1 条规则</small></span></button>
<button><span class="mini-thumb"></span><span><strong>宿舍东通道</strong><small>在线 · 3 条规则</small></span></button>
<button><span class="mini-thumb"></span><span><strong>配电房门口</strong><small>在线 · 1 条规则</small></span></button>
</div></aside>
<section class="card zone-workspace" aria-label="区域绘制工作区">
<div class="editor-tools" role="toolbar" aria-label="区域绘制工具">
<button class="tool-btn" data-tool="pointer" aria-pressed="false" title="选择对象"><svg class="icon"><use href="#i-pointer"/></svg><span>选择</span></button>
<button class="tool-btn active" data-tool="polygon" aria-pressed="true" title="绘制多边形"><svg class="icon"><use href="#i-zone"/></svg><span>多边形</span></button>
<button class="tool-btn" data-tool="line" aria-pressed="false" title="绘制方向警戒线"><svg class="icon"><use href="#i-line"/></svg><span>警戒线</span></button>
<span class="tool-divider"></span>
<button class="tool-btn" id="undoPoint" title="撤销最后一个点"><svg class="icon"><use href="#i-undo"/></svg><span>撤销</span></button>
<button class="tool-btn" id="clearPoints" title="清空当前草稿"><svg class="icon"><use href="#i-trash"/></svg><span>清空</span></button>
</div>
<div class="editor-stage">
<svg id="zoneCanvas" viewBox="0 0 960 540" role="img" aria-label="北围墙 01 子码流快照及区域叠加;可点击添加多边形顶点">
<defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#10283b"/><stop offset="1" stop-color="#07111d"/></linearGradient><pattern id="scan" width="8" height="8" patternUnits="userSpaceOnUse"><path d="M0 8h8" stroke="#ffffff" stroke-opacity=".035"/></pattern></defs>
<rect width="960" height="540" fill="url(#sky)"/><path d="M0 330 160 250 330 315 510 235 690 310 820 225 960 282V540H0Z" fill="#13283a"/><rect y="410" width="960" height="130" fill="#101a22"/><path d="M0 414h960M0 472h960" stroke="#5c7489" stroke-opacity=".45"/><path d="M170 300v240m230-245v245m250-250v250m190-280v280" stroke="#6f879b" stroke-width="4"/><path d="M0 345h960" stroke="#7890a4" stroke-width="5"/><rect width="960" height="540" fill="url(#scan)"/>
<polygon points="105,365 285,310 535,335 505,466 135,472" fill="#38bdf8" fill-opacity=".18" stroke="#38bdf8" stroke-width="4" stroke-dasharray="10 7"/>
<g fill="#07111d" stroke="#7dd3fc" stroke-width="4"><circle cx="105" cy="365" r="7"/><circle cx="285" cy="310" r="7"/><circle cx="535" cy="335" r="7"/><circle cx="505" cy="466" r="7"/><circle cx="135" cy="472" r="7"/></g>
<line x1="650" y1="340" x2="855" y2="430" stroke="#fbbf24" stroke-width="5"/><path d="m760 382 30-2-18 24" fill="none" stroke="#fbbf24" stroke-width="5"/><g id="draftLayer"></g>
<text x="26" y="38" fill="#d6e8f5" font-size="18" font-family="sans-serif">北围墙 01 · 子码流预览</text><text x="26" y="66" fill="#88a4b8" font-size="14" font-family="sans-serif">CAM-S2-001 · 704 × 576 · 5 FPS</text>
</svg>
<div class="editor-hint" id="editorHint">多边形:点击画面添加顶点,双击闭合;也可在右侧输入坐标。</div>
</div>
<div class="editor-status"><span id="pointStatus">当前草稿:0 个点</span><span>缩放 100% · 网格吸附关闭</span></div>
</section>
<aside class="card zone-settings"><div class="card-head"><h2>规则属性</h2><button class="btn btn-quiet icon-btn" aria-label="更多规则操作"><svg class="icon"><use href="#i-more"/></svg></button></div><div class="card-body">
<div class="form-grid">
<div class="form-field full"><label for="zoneName">名称</label><input id="zoneName" class="field" value="北围墙危险区域"></div>
<div class="form-field"><label for="zoneType">空间类型</label><select id="zoneType" class="select"><option>禁入区域</option><option>方向警戒线</option><option>关注区域</option></select></div>
<div class="form-field"><label for="zoneColor">叠加颜色</label><select id="zoneColor" class="select"><option>蓝色</option><option>琥珀色</option><option>红色</option></select></div>
<div class="form-field full"><label for="zoneRule">绑定规则</label><select id="zoneRule" class="select"><option>非授权人员进入</option><option>翻越围墙</option><option>区域内聚集</option></select></div>
<div class="form-field"><label for="schedule">生效时段</label><select id="schedule" class="select"><option>全天</option><option>夜间 22:00–06:00</option><option>自定义课表</option></select></div>
<div class="form-field"><label for="duration">持续时间</label><select id="duration" class="select"><option>立即</option><option>超过 3 秒</option><option>超过 10 秒</option></select></div>
<div class="form-field full"><div class="switch-row"><span><strong>试运行</strong><br><small class="muted">命中只记录,不触发正式预警</small></span><label class="switch"><input id="dryRun" type="checkbox" checked aria-label="启用试运行"><span></span></label></div></div>
</div>
<div class="coordinate-box"><h3>键盘坐标编辑</h3><p>坐标范围 0–100%,为鼠标绘制提供等效操作。</p><div class="coordinate-row"><input class="field" id="coordX" type="number" min="0" max="100" value="50" aria-label="横坐标百分比"><input class="field" id="coordY" type="number" min="0" max="100" value="50" aria-label="纵坐标百分比"><button class="btn" id="addCoordinate">添加点</button></div><ol id="pointList" class="muted" aria-live="polite"><li>尚未添加草稿点</li></ol></div>
<div class="settings-actions"><button class="btn" id="discardDraft">放弃</button><button class="btn" id="saveDraft">保存草稿</button></div>
</div></aside>
</div>
</section>
<section class="view" data-page="onboarding">
<div class="page-head"><div><h1>接入任务</h1><p>默认 16 路也支持批量导入;128 路时不依赖逐路手工操作。</p></div><div class="page-actions"><button class="btn">下载 CSV 模板</button></div></div>
<div class="grid split">
<article class="card"><div class="card-head"><h2>导入摄像头</h2><span class="badge info">配额 16 / 128</span></div><div class="card-body"><div class="drop-zone"><svg class="icon"><use href="#i-upload"/></svg><strong>选择 CSV 文件或拖到这里</strong><p>先本地校验列、重复序列号和配额,不上传明文密码。</p><button class="btn btn-primary" id="fakeUpload">选择文件</button></div></div></article>
<article class="card"><div class="card-head"><h2>当前任务</h2><span class="badge warning">进行中</span></div><div class="card-body"><p><strong>宿舍区第二批 · 8 路</strong></p><div class="meter"><i style="width:62%"></i></div><p class="muted">已验证 5 / 8 · 2 路待补凭据 · 1 路重试中</p><button class="btn">查看逐项结果</button></div></article>
</div>
</section>
<section class="view" data-page="system">
<div class="page-head"><div><h1>系统状态</h1><p>MediaMTX、数据库和对账器只是可观察组件,不替代设备台账真相。</p></div><div class="page-actions"><button class="btn"><svg class="icon"><use href="#i-refresh"/></svg>刷新</button></div></div>
<div class="grid system-grid">
<article class="card system-tile"><h3>Sense 服务</h3><dl><dt>状态</dt><dd><span class="badge success">健康</span></dd><dt>运行时间</dt><dd>12d 06h</dd><dt>待处理队列</dt><dd>2</dd></dl></article>
<article class="card system-tile"><h3>MediaMTX</h3><dl><dt>实例</dt><dd>2 / 2</dd><dt>Readers</dt><dd>15</dd><dt>重连</dt><dd>1</dd></dl></article>
<article class="card system-tile"><h3>本地资源</h3><dl><dt>CPU</dt><dd>22%</dd><dt>内存</dt><dd>4.8 / 16 GB</dd><dt>事件缓存</dt><dd>86 MB</dd></dl></article>
</div>
</section>
</div>
</main>
</div>
<nav class="mobile-nav" aria-label="移动端主导航">
<button data-view="overview">总览</button><button data-view="monitor">监控</button><button data-view="devices">设备</button><button class="active" data-view="zones">区域</button><button data-view="system">系统</button>
</nav>
<dialog id="cameraDialog" aria-labelledby="cameraDialogTitle">
<form method="dialog">
<div class="dialog-head"><h2 id="cameraDialogTitle">添加摄像头</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="cameraName">名称</label><input class="field" id="cameraName" required value="北围墙 04"></div>
<div class="form-field"><label for="cameraProtocol">协议</label><select class="select" id="cameraProtocol"><option>ONVIF</option><option>RTSP</option></select></div>
<div class="form-field full"><label for="cameraEndpoint">设备地址</label><input class="field" id="cameraEndpoint" value="192.0.2.24:80" aria-describedby="endpointHelp"><small id="endpointHelp" class="muted">原型使用保留地址;保存后列表仅显示脱敏端点。</small></div>
<div class="form-field"><label for="cameraUser">用户名</label><input class="field" id="cameraUser" value="operator"></div>
<div class="form-field"><label for="cameraPassword">密码</label><input class="field" id="cameraPassword" type="password" value="prototype-only"></div>
<div class="form-field"><label for="cameraArea">区域</label><select class="select" id="cameraArea"><option>北围墙</option><option>实验楼</option><option>宿舍区</option></select></div>
<div class="form-field"><label for="cameraSerial">序列号</label><input class="field" id="cameraSerial" value="CAM-DEMO-004"></div>
</div><p><button type="button" class="btn" id="testConnection">测试连接</button> <span id="testResult" class="muted" aria-live="polite">保存前先验证 Profiles、StreamUri 与校时能力。</span></p></div>
<div class="dialog-foot"><button class="btn" value="cancel">取消</button><button class="btn btn-primary" id="saveCamera" value="default">保存为待激活</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'), 3600); }
const deviceData = [
['北围墙 01','CAM-S2-001','启用','在线','刚刚','+0.2s','media-01','success'],
['北围墙 03','CAM-S2-003','启用','重连中','2 分钟前','—','media-01','warning'],
['实验楼入口 02','CAM-S2-006','启用','在线','12 秒前','+3.8s','media-02','warning'],
['宿舍东通道','CAM-S2-011','启用','在线','4 秒前','+0.1s','media-02','success'],
['配电房门口','CAM-S2-015','停用','已停止','1 天前','—','未绑定','info']
];
$('#deviceRows').innerHTML = deviceData.map(d => `<tr><td><input type="checkbox" aria-label="选择 ${d[0]}"></td><td><div class="device-name"><span class="camera-icon"><svg class="icon"><use href="#i-camera"/></svg></span><span><strong>${d[0]}</strong><small>${d[1]}</small></span></div></td><td>${d[2]}</td><td><span class="badge ${d[7]}">${d[3]}</span></td><td>${d[4]}</td><td>${d[5]}</td><td>${d[6]}</td><td><button class="btn btn-quiet icon-btn" aria-label="${d[0]} 更多操作"><svg class="icon"><use href="#i-more"/></svg></button></td></tr>`).join('');
const cameraNames = ['北围墙 01','实验楼入口 02','宿舍东通道','配电房门口'];
$('#cameraGrid').innerHTML = cameraNames.map((name, i) => `<article class="card camera-card"><div class="camera-stage"><svg class="scene" viewBox="0 0 640 360" role="img" aria-label="${name} 模拟子码流画面"><rect width="640" height="360" fill="#07111d"/><path d="M0 ${250-i*12} 140 180 310 230 460 150 640 210V360H0Z" fill="#14283a"/><path d="M0 290h640M100 210v150m210-165v165m220-190v190" stroke="#647b8e" stroke-width="4"/><rect width="640" height="360" fill="none" stroke="#ffffff" stroke-opacity=".08"/><text x="18" y="28" fill="#d7e7f3" font-size="16">${name} · 子码流</text><circle cx="598" cy="27" r="5" fill="#34d399"/></svg></div><div class="camera-meta"><span><strong>${name}</strong><small>704 × 576 · 5 FPS</small></span><span class="badge success">在线</span></div></article>`).join('');
function switchView(name) {
$$('.view').forEach(v => v.classList.toggle('active', v.dataset.page === name));
$$('[data-view]').forEach(b => { const current = b.dataset.view === name; b.toggleAttribute('aria-current', current); b.classList.toggle('active', current); });
$('#stateSelect').value = 'normal'; renderState('normal');
$('#main-content').focus({preventScroll:true});
}
$$('[data-view]').forEach(b => b.addEventListener('click', () => switchView(b.dataset.view)));
function renderState(state) {
const layer = $('#stateLayer'); const views = $$('.view');
layer.classList.toggle('show', state !== 'normal'); views.forEach(v => v.style.display = state === 'normal' ? '' : 'none');
if (state === 'normal') return;
const configs = {
loading: `<div><div class="skeleton" aria-label="正在加载"><i></i><i></i><i></i><i></i></div><h2>正在同步边缘状态</h2><p>超过 300ms 时显示进度;已有视频流不因控制面加载而中断。</p></div>`,
empty: `<div><svg class="icon"><use href="#i-camera"/></svg><h2>这个站点还没有摄像头</h2><p>先扫描 ONVIF 设备,或下载 CSV 模板进行批量导入。</p><button class="btn btn-primary" onclick="switchView('onboarding')">开始接入</button></div>`,
error: `<div><svg class="icon" style="color:var(--danger)"><use href="#i-alert"/></svg><h2>无法读取最新状态</h2><p>边缘节点暂时不可达。当前显示可能已过期,系统不会把未知状态伪装为在线。</p><button class="btn" onclick="document.querySelector('#stateSelect').value='normal';renderState('normal')">重试</button></div>`
}; layer.innerHTML = configs[state];
}
$('#stateSelect').addEventListener('change', e => renderState(e.target.value));
$('#themeToggle').addEventListener('click', () => { const html = document.documentElement; html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark'; notify(`已切换为${html.dataset.theme === 'dark' ? '深色' : '浅色'}主题`); });
const zoneCanvas = $('#zoneCanvas'), draftLayer = $('#draftLayer'), points = [];
let activeTool = 'polygon';
function drawDraft() {
const coords = points.map(p => `${p.x},${p.y}`).join(' ');
const shape = activeTool === 'line' && points.length > 1 ? `<line x1="${points[0].x}" y1="${points[0].y}" x2="${points[1].x}" y2="${points[1].y}" stroke="#fbbf24" stroke-width="5"/>` : `<polyline points="${coords}" fill="${points.length > 2 ? 'rgba(167,139,250,.18)' : 'none'}" stroke="#a78bfa" stroke-width="4" stroke-dasharray="8 6"/>`;
draftLayer.innerHTML = shape + points.map((p,i) => `<circle cx="${p.x}" cy="${p.y}" r="8" fill="#08111f" stroke="#c4b5fd" stroke-width="4"><title>点 ${i+1}</title></circle>`).join('');
$('#pointStatus').textContent = `当前草稿:${points.length} 个点`;
$('#pointList').innerHTML = points.length ? points.map((p,i) => `<li>点 ${i+1}:${(p.x/9.6).toFixed(1)}%, ${(p.y/5.4).toFixed(1)}%</li>`).join('') : '<li>尚未添加草稿点</li>';
$('#draftBadge').textContent = points.length ? '草稿有未保存修改' : '草稿未发布';
}
function addPoint(x,y) { if (activeTool === 'pointer') return; if (activeTool === 'line' && points.length >= 2) points.length = 0; points.push({x:Math.max(0,Math.min(960,x)),y:Math.max(0,Math.min(540,y))}); drawDraft(); }
zoneCanvas.addEventListener('click', e => { const r = zoneCanvas.getBoundingClientRect(); addPoint((e.clientX-r.left)/r.width*960,(e.clientY-r.top)/r.height*540); });
zoneCanvas.addEventListener('dblclick', e => { e.preventDefault(); if (activeTool === 'polygon' && points.length >= 3) notify(`多边形已闭合,共 ${points.length} 个顶点;请保存草稿。`); });
$$('.tool-btn[data-tool]').forEach(btn => btn.addEventListener('click', () => { activeTool = btn.dataset.tool; $$('.tool-btn[data-tool]').forEach(b => { const on = b === btn; b.classList.toggle('active', on); b.setAttribute('aria-pressed', on); }); points.length = 0; drawDraft(); $('#editorHint').textContent = activeTool === 'line' ? '警戒线:依次添加起点与终点;箭头方向由起点指向终点。' : activeTool === 'pointer' ? '选择:查看已有区域;切换绘制工具可新建草稿。' : '多边形:点击画面添加顶点,双击闭合;也可在右侧输入坐标。'; }));
$('#undoPoint').addEventListener('click', () => { points.pop(); drawDraft(); });
$('#clearPoints').addEventListener('click', () => { if (points.length && confirm('清空当前未保存的绘制草稿?')) { points.length=0; drawDraft(); } });
$('#addCoordinate').addEventListener('click', () => { const x=Number($('#coordX').value), y=Number($('#coordY').value); if (!Number.isFinite(x)||!Number.isFinite(y)||x<0||x>100||y<0||y>100) { notify('坐标必须在 0–100% 之间。'); return; } addPoint(x*9.6,y*5.4); });
$('#discardDraft').addEventListener('click', () => { if (!points.length || confirm('放弃未保存的区域修改?')) { points.length=0; drawDraft(); notify('已放弃本次修改,正式规则未受影响。'); } });
$('#saveDraft').addEventListener('click', () => { if ((activeTool==='polygon'&&points.length<3)||(activeTool==='line'&&points.length<2)) { notify(activeTool==='line'?'警戒线至少需要 2 个点。':'多边形至少需要 3 个点。'); return; } notify('草稿已保存,尚未正式发布。'); $('#draftBadge').textContent='草稿已保存'; });
$('#publishRule').addEventListener('click', () => { if (confirm('发布后将影响“北围墙 01”的正式规则。确认发布当前已保存草稿?')) { $('#draftBadge').className='badge success'; $('#draftBadge').textContent='规则已发布'; notify('规则版本 v7 已发布,可在版本记录中回滚。'); } });
$('#dryRun').addEventListener('change', e => notify(e.target.checked?'已启用试运行:命中不会触发正式预警。':'已关闭试运行:发布后将进入正式链路。'));
const dialog = $('#cameraDialog');
$('#openCameraDialog').addEventListener('click', () => dialog.showModal());
$('#testConnection').addEventListener('click', async e => { const b=e.currentTarget; b.disabled=true; b.textContent='测试中…'; $('#testResult').textContent='正在读取 Profiles 与设备时间'; await new Promise(r=>setTimeout(r,700)); b.disabled=false; b.textContent='重新测试'; $('#testResult').innerHTML='<span style="color:var(--success)">连接成功:2 个 Profile,时间漂移 +0.4s</span>'; });
$('#saveCamera').addEventListener('click', () => notify('摄像头已保存为“待激活”,凭据不会显示在列表中。'));
$('#fakeUpload').addEventListener('click', () => notify('演示:已选择 cameras-demo.csv,检测到 1 个重复序列号。'));
drawDraft();
</script>
</body>
</html>