Compare commits

...
14 changed files with 1592 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
# YoVision 当前 MVP 交互原型
本目录是工单 #28 生成的版本绑定设计资产,用于确认 MVP #8 的信息架构、页面状态和交互流程,不代表产品功能已经实现。
## 打开方式
可直接双击 `index.html`,或在仓库根目录启动本地静态服务:
```powershell
python -m http.server 4173 --directory prototypes
```
然后访问 `http://127.0.0.1:4173/`。
## 原型范围
- `sense/index.html`:设备接入、ONVIF/RTSP、MediaMTX、实时监看和区域配置;直接映射 go-admin-ui 与 Element Plus 的通用管理端组件。
- `brain/index.html`:合成输入到项目内匿名事件的内部流水线;Brain 无客户管理 UI。
- `bell/index.html`:合成事件、不可变 Event、规则/Alert、ack/close 和审计时间线;直接映射 go-admin-ui 与 Element Plus 的通用管理端组件。
- `assets/`:三套原型复用的基础样式和交互脚本;Sense/Bell 共同复用 go-admin-ui 原型外壳适配器。
- `design-system/yovision/MASTER.md`:由 UI/UX Pro Max 生成的设计系统基线。
## 交互提示
- 左侧导航可切换页面;移动端使用顶部菜单按钮。
- Sense 可筛选设备、添加待激活设备并查看区域配置。
- Brain 可模拟处理下一帧并生成项目内事件。
- Bell 可注入合成事件,并演示 Alert 的 ack 和 close。
- 所有弹窗支持 `Esc` 关闭,交互元素支持键盘焦点。
## Sense / Bell 的 go-admin-ui 对齐基线
- 只读参考仓库:`D:\github\goadmin\go-admin-ui`。
- 参考 commit:`67d393d713877572fab0b897296a4c1d525fc81d`。
- 应用外壳映射:`Sidebar`、`Navbar`、`TagsView`、`AppMain` 和 `BasicLayout` / 页面容器。
- 通用组件映射:Element Plus 的 Button、Form、Input/Select、Table、Pagination、Dialog 和 Tag,以及 go-admin-ui 的搜索区与 `v-permisaction` 权限按钮模式。
- 共用原型适配器:`assets/go-admin-ui.css` 与 `assets/go-admin-shell.js`;两套产品不复制应用外壳和通用组件原型代码。
- Sense 专用组件:视频舞台 `SenseVideoStage` 与区域画布 `SenseZoneCanvas`。
- Bell 专用组件:值班处置工作台 `AlertWorkbench` 与审计时间线 `AlertAuditTimeline`。
- 新增专用组件前必须先确认 go-admin-ui / Element Plus 中没有等价组件。
- 原型仍是无构建依赖的 HTML/CSS/JavaScript,并未嵌入或复制 go-admin-ui / Element Plus 源码;这些名称用于约束正式 Vue 实现的组件落点。
## 事实来源边界
- 产品范围以 Gitea Wiki 的 Product Requirements、Product Roadmap 和对应工单为准。
- 本原型不定义 API、数据库或跨项目契约。
- 旧仓库原型仅用于只读参考;本目录没有复制旧原型代码。
- 页面数据均为虚构演示数据,不得放入真实账号、摄像头凭据、客户数据或生产地址。
+203
View File
@@ -0,0 +1,203 @@
(function () {
"use strict";
const qs = (selector, root = document) => root.querySelector(selector);
const qsa = (selector, root = document) => [...root.querySelectorAll(selector)];
const toastRegion = qs("[data-toast-region]");
let lastFocused = null;
function toast(message) {
if (!toastRegion) return;
const item = document.createElement("div");
item.className = "toast";
item.setAttribute("role", "status");
item.textContent = message;
toastRegion.appendChild(item);
window.setTimeout(() => item.remove(), 3600);
}
function setView(name) {
qsa("[data-view]").forEach((view) => {
const active = view.dataset.view === name;
view.classList.toggle("active", active);
view.hidden = !active;
});
qsa("[data-view-target]").forEach((button) => {
const active = button.dataset.viewTarget === name;
button.classList.toggle("active", active);
if (active) button.setAttribute("aria-current", "page");
else button.removeAttribute("aria-current");
});
const selected = qs(`[data-view-target="${name}"]`);
const title = selected?.dataset.title || selected?.textContent.trim();
const pageName = qs("[data-page-name]");
if (pageName && title) pageName.textContent = title;
qs("#main-content")?.focus({ preventScroll: true });
qs("[data-sidebar]")?.classList.remove("open");
qs("[data-mobile-menu]")?.setAttribute("aria-expanded", "false");
}
qsa("[data-view-target]").forEach((button) => {
button.addEventListener("click", () => setView(button.dataset.viewTarget));
});
qs("[data-mobile-menu]")?.addEventListener("click", () => {
const sidebar = qs("[data-sidebar]");
const open = sidebar.classList.toggle("open");
qs("[data-mobile-menu]").setAttribute("aria-expanded", String(open));
});
function openModal(id, trigger) {
const backdrop = qs(`#${id}`);
if (!backdrop) return;
lastFocused = trigger || document.activeElement;
backdrop.classList.add("open");
backdrop.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
qs("input, select, textarea, button", qs(".modal", backdrop))?.focus();
}
function closeModal(backdrop) {
if (!backdrop) return;
backdrop.classList.remove("open");
backdrop.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
lastFocused?.focus();
}
qsa("[data-open-modal]").forEach((trigger) => {
trigger.addEventListener("click", () => openModal(trigger.dataset.openModal, trigger));
});
qsa("[data-close-modal]").forEach((trigger) => {
trigger.addEventListener("click", () => closeModal(trigger.closest(".modal-backdrop")));
});
qsa(".modal-backdrop").forEach((backdrop) => {
backdrop.addEventListener("mousedown", (event) => {
if (event.target === backdrop) closeModal(backdrop);
});
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeModal(qs(".modal-backdrop.open"));
});
qsa("[role='tab'][data-tab-target]").forEach((tab) => {
tab.addEventListener("click", () => {
const group = tab.closest("[data-tabs]");
qsa("[role='tab']", group).forEach((item) => {
item.setAttribute("aria-selected", String(item === tab));
item.tabIndex = item === tab ? 0 : -1;
});
qsa(".tab-panel", group.parentElement).forEach((panel) => {
const active = panel.id === tab.dataset.tabTarget;
panel.classList.toggle("active", active);
panel.hidden = !active;
});
});
});
qsa("[data-filter-input]").forEach((input) => {
input.addEventListener("input", () => {
const table = qs(input.dataset.filterInput);
const query = input.value.trim().toLocaleLowerCase("zh-CN");
qsa("tbody tr", table).forEach((row) => {
row.hidden = query && !row.textContent.toLocaleLowerCase("zh-CN").includes(query);
});
});
});
qsa("[data-toast]").forEach((button) => {
button.addEventListener("click", () => toast(button.dataset.toast));
});
const addDevice = qs("[data-action='add-device']");
addDevice?.addEventListener("click", () => {
const name = qs("#device-name")?.value.trim() || "新摄像头";
const address = qs("#device-address")?.value.trim() || "192.168.10.88";
const tbody = qs("#device-table tbody");
if (tbody) {
const row = document.createElement("tr");
row.innerHTML = `<td><span class="table-title"></span><span class="table-subtitle">待探测 · video</span></td><td></td><td><span class="status status-warning">待激活</span></td><td>尚未选择</td><td><button class="btn btn-quiet" data-toast="已打开设备探测流程">查看</button></td>`;
qs(".table-title", row).textContent = name;
row.children[1].textContent = address;
qs("button", row).addEventListener("click", () => toast("已打开设备探测流程"));
tbody.prepend(row);
}
closeModal(addDevice.closest(".modal-backdrop"));
toast(`${name} 已保存为待激活设备`);
});
let frameCount = 12842;
qs("[data-action='run-frame']")?.addEventListener("click", (event) => {
event.currentTarget.disabled = true;
event.currentTarget.textContent = "处理中…";
window.setTimeout(() => {
frameCount += 1;
const counter = qs("[data-frame-count]");
if (counter) counter.textContent = frameCount.toLocaleString("zh-CN");
const events = qs("[data-brain-events]");
if (events) {
const item = document.createElement("div");
item.className = "activity-item";
item.innerHTML = `<div class="activity-icon"><svg aria-hidden="true"><use href="#icon-activity"></use></svg></div><div><div class="activity-title">方向越线候选</div><div class="activity-meta">synthetic-camera-01 · 置信度 0.91 · 项目内事件</div></div><div class="activity-time">刚刚</div>`;
events.prepend(item);
}
event.currentTarget.disabled = false;
event.currentTarget.textContent = "处理下一帧";
toast("已完成一帧推理并生成项目内候选事件");
}, 520);
});
qs("[data-action='inject-event']")?.addEventListener("click", (event) => {
const button = event.currentTarget;
const idleText = button.textContent;
button.disabled = true;
button.textContent = "注入中…";
window.setTimeout(() => {
const count = qs("[data-alert-count]");
if (count) count.textContent = String(Number(count.textContent) + 1);
button.disabled = false;
button.textContent = idleText;
closeModal(button.closest(".modal-backdrop"));
toast("合成事件已校验并生成新的 Alert");
}, 520);
});
const alertState = qs("[data-alert-state]");
const ackButton = qs("[data-action='ack-alert']");
const closeButton = qs("[data-action='close-alert']");
ackButton?.addEventListener("click", () => {
alertState.textContent = "已确认";
alertState.className = "status status-warning el-tag";
ackButton.disabled = true;
closeButton.disabled = false;
const timeline = qs("[data-alert-timeline]");
timeline?.insertAdjacentHTML("beforeend", `<div class="timeline-item"><div class="timeline-title">王值班员确认预警</div><div class="timeline-meta">刚刚 · 首个成功 ack · 已写入审计</div></div>`);
toast("预警已由王值班员确认");
});
closeButton?.addEventListener("click", () => {
alertState.textContent = "已关闭";
alertState.className = "status status-success el-tag";
closeButton.disabled = true;
qs("[data-alert-timeline]")?.insertAdjacentHTML("beforeend", `<div class="timeline-item"><div class="timeline-title">处置完成并关闭</div><div class="timeline-meta">刚刚 · 原始 Event 保持不变</div></div>`);
toast("Alert 已关闭,原始 Event 未修改");
});
qsa("[data-select-alert]").forEach((row) => {
const select = () => {
qsa("[data-select-alert]").forEach((item) => item.classList.toggle("selected", item === row));
toast(`已选择:${row.dataset.selectAlert}`);
};
row.addEventListener("click", select);
if (row.tagName !== "BUTTON") {
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
select();
}
});
}
});
const initial = qs("[data-view-target].active")?.dataset.viewTarget || qs("[data-view]")?.dataset.view;
if (initial) setView(initial);
})();
+41
View File
@@ -0,0 +1,41 @@
(function () {
"use strict";
const shell = document.querySelector("[data-go-admin-shell]");
const collapse = document.querySelector("[data-collapse-sidebar]");
function syncTags(viewName) {
document.querySelectorAll(".tags-view-item").forEach((tag) => {
const active = tag.dataset.viewTarget === viewName;
tag.classList.toggle("active", active);
if (active) tag.setAttribute("aria-current", "page");
else tag.removeAttribute("aria-current");
});
}
document.querySelectorAll("[data-view-target]").forEach((trigger) => {
trigger.addEventListener("click", () => syncTags(trigger.dataset.viewTarget));
});
if (collapse && shell) {
collapse.addEventListener("click", () => {
const compact = shell.classList.toggle("compact");
collapse.setAttribute("aria-expanded", String(!compact));
collapse.setAttribute("aria-label", compact ? "展开主导航" : "折叠主导航");
});
}
document.querySelectorAll("[data-go-admin-query]").forEach((form) => {
const input = document.querySelector(form.dataset.queryInput || "");
form.addEventListener("submit", (event) => {
event.preventDefault();
input?.dispatchEvent(new Event("input", { bubbles: true }));
});
form.addEventListener("reset", () => {
window.setTimeout(() => input?.dispatchEvent(new Event("input", { bubbles: true })), 0);
});
});
const initialView = document.querySelector("[data-view-target].active")?.dataset.viewTarget;
if (initialView) syncTags(initialView);
})();
+182
View File
@@ -0,0 +1,182 @@
/*
* Shared prototype adapter for go-admin-ui commit 67d393d713877572fab0b897296a4c1d525fc81d.
* This is an offline visual/interaction mapping, not copied framework CSS and not an Element Plus runtime.
*/
.go-admin-prototype {
--ga-primary: #1677ff;
--ga-primary-hover: #409eff;
--ga-sidebar: #001529;
--ga-sidebar-hover: #000c17;
--ga-app-bg: #f0f2f5;
--ga-border: #e4e7ed;
--ga-text: #303133;
--ga-muted: #606266;
--ga-sidebar-width: 210px;
color: var(--ga-text);
background: var(--ga-app-bg);
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
}
.go-admin-prototype .go-admin-shell { grid-template-columns: var(--ga-sidebar-width) minmax(0, 1fr); }
.go-admin-prototype .sidebar-container { width: var(--ga-sidebar-width); padding: 0; background: var(--ga-sidebar); box-shadow: 2px 0 6px rgba(0, 21, 41, .35); transition: width .28s ease, transform .28s ease; }
.go-admin-prototype .sidebar-logo { height: 64px; min-height: 64px; padding: 0 16px; gap: 12px; color: #fff; text-decoration: none; background: #001529; }
.go-admin-prototype .brand-mark { width: 34px; height: 34px; flex: 0 0 34px; border-radius: 5px; background: var(--ga-primary); }
.go-admin-prototype .brand-copy { min-width: 0; }
.go-admin-prototype .brand-name { white-space: nowrap; }
.go-admin-prototype .brand-subtitle { color: rgba(255,255,255,.56); white-space: nowrap; }
.go-admin-prototype .el-menu { gap: 0; padding: 8px 0; }
.go-admin-prototype .menu-group,
.go-admin-prototype .el-menu-item { min-height: 50px; height: 50px; padding: 0 18px; border-radius: 0; gap: 14px; color: rgba(255,255,255,.82); }
.go-admin-prototype .menu-group { display: flex; align-items: center; font-size: 14px; font-weight: 500; }
.go-admin-prototype .menu-group .menu-arrow { width: 14px; margin-left: auto; transform: rotate(90deg); }
.go-admin-prototype .el-menu-item:hover { color: #fff; background: var(--ga-sidebar-hover); }
.go-admin-prototype .el-menu-item.active { color: var(--ga-primary); background: var(--ga-sidebar-hover); box-shadow: inset 3px 0 0 var(--ga-primary); }
.go-admin-prototype .side-note { margin: 12px; padding: 11px 12px; border-radius: 4px; color: rgba(255,255,255,.72); }
.go-admin-prototype .main-container { min-width: 0; background: var(--ga-app-bg); }
.go-admin-prototype .fixed-header { position: sticky; top: 0; z-index: 30; }
.go-admin-prototype .navbar { position: relative; min-height: 50px; height: 50px; padding: 0 16px 0 0; border-bottom: 1px solid #eef0f6; background: #fff; box-shadow: 0 1px 8px rgba(79,70,229,.06); backdrop-filter: none; }
.go-admin-prototype .navbar::after { content: ""; position: absolute; right: 0; bottom: 0; left: 0; height: 2px; background: linear-gradient(90deg, #1677ff, #40a9ff); opacity: .7; }
.go-admin-prototype .navbar-action,
.go-admin-prototype .avatar-button { min-width: 46px; height: 48px; padding: 0 12px; border: 0; display: inline-flex; align-items: center; justify-content: center; gap: 7px; background: transparent; color: #64748b; }
.go-admin-prototype .navbar-action:hover,
.go-admin-prototype .avatar-button:hover { color: var(--ga-primary); background: rgba(22,119,255,.06); }
.go-admin-prototype .navbar-action svg { width: 19px; height: 19px; }
.go-admin-prototype .mobile-menu { display: none; }
.go-admin-prototype .breadcrumb { display: flex; align-items: center; gap: 9px; color: #97a8be; font-size: 13px; white-space: nowrap; }
.go-admin-prototype .breadcrumb a { color: #606266; text-decoration: none; }
.go-admin-prototype .breadcrumb strong { color: #303133; font-weight: 500; }
.go-admin-prototype .avatar-button { min-width: auto; }
.go-admin-prototype .avatar { width: 32px; height: 32px; display: grid; place-items: center; border: 2px solid rgba(22,119,255,.2); border-radius: 50%; color: var(--ga-primary); }
.go-admin-prototype .prototype-pill { min-height: 25px; border-radius: 4px; }
.go-admin-prototype .tags-view-container { height: 40px; padding: 8px 8px 0; display: flex; align-items: flex-end; gap: 3px; overflow-x: auto; border-bottom: 1px solid #e8eaec; background: #fff; box-shadow: 0 1px 4px rgba(0,21,41,.08); }
.go-admin-prototype .tags-view-item { height: 32px; padding: 0 10px; border: 1px solid #e0e0e6; border-radius: 3px 3px 0 0; flex: 0 0 auto; background: #f8f9fa; color: #606266; font-size: 12px; }
.go-admin-prototype .tags-view-item:hover { color: var(--ga-primary); background: #e6f4ff; }
.go-admin-prototype .tags-view-item.active { color: var(--ga-primary); border-color: #91caff; border-bottom-color: #fff; background: #fff; font-weight: 500; }
.go-admin-prototype .tags-view-item span { margin-left: 4px; color: #909399; }
.go-admin-prototype .app-main { width: 100%; min-height: calc(100vh - 90px); padding: 16px; background: var(--ga-app-bg); }
.go-admin-prototype .compact-header { margin-bottom: 14px; }
.go-admin-prototype .compact-header h1 { font-size: 20px; font-weight: 500; letter-spacing: 0; }
.go-admin-prototype .compact-header p { font-size: 13px; }
.go-admin-prototype .el-card { border-color: var(--ga-border); border-radius: 4px; box-shadow: 0 1px 2px rgba(0,0,0,.03); }
.go-admin-prototype .card-header { min-height: 48px; padding: 10px 14px; }
.go-admin-prototype .card-body { padding: 14px; }
.go-admin-prototype .metric { padding: 14px; }
.go-admin-prototype .el-button { min-height: 32px; height: 32px; padding: 5px 12px; border-color: #dcdfe6; border-radius: 4px; background: #fff; color: #606266; font-size: 12px; font-weight: 400; }
.go-admin-prototype .el-button svg { width: 15px; height: 15px; }
.go-admin-prototype .el-button:hover { color: var(--ga-primary); border-color: #a0cfff; background: #ecf5ff; }
.go-admin-prototype .el-button-primary { color: #fff; border-color: var(--ga-primary); background: var(--ga-primary); }
.go-admin-prototype .el-button-primary:hover { color: #fff; border-color: var(--ga-primary-hover); background: var(--ga-primary-hover); }
.go-admin-prototype .el-button-link { min-height: 30px; height: 30px; padding: 3px 4px; border: 0; color: var(--ga-primary); background: transparent; }
.go-admin-prototype .el-button:disabled { color: #a8abb2; border-color: #e4e7ed; background: #f5f7fa; }
.go-admin-prototype .el-form { color: var(--ga-text); }
.go-admin-prototype .el-form-inline { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 18px; padding-bottom: 14px; border-bottom: 1px solid #ebeef5; }
.go-admin-prototype .form-item { min-width: 180px; display: grid; grid-template-columns: auto minmax(150px, 220px); align-items: center; gap: 8px; }
.go-admin-prototype .form-item label { color: #606266; font-size: 13px; white-space: nowrap; }
.go-admin-prototype .form-actions { display: flex; gap: 8px; }
.go-admin-prototype .el-input,
.go-admin-prototype .el-select { min-height: 32px; height: 32px; padding: 4px 10px; border-color: #dcdfe6; border-radius: 4px; color: #606266; font-size: 13px; }
.go-admin-prototype .el-input:focus,
.go-admin-prototype .el-select:focus { border-color: var(--ga-primary); outline: 2px solid rgba(22,119,255,.16); }
.go-admin-prototype .vertical-form { display: grid; gap: 16px; }
.go-admin-prototype .vertical-form .form-item { grid-template-columns: 92px minmax(0,1fr); }
.go-admin-prototype .vertical-form .help { margin: -8px 0 0 100px; }
.go-admin-prototype .form-footer { display: flex; justify-content: flex-end; }
.go-admin-prototype .operation-bar { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.go-admin-prototype .operation-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.go-admin-prototype .permission-hint { display: inline-flex; align-items: center; gap: 5px; color: #909399; font-size: 12px; }
.go-admin-prototype .permission-hint svg { width: 14px; height: 14px; }
.go-admin-prototype .el-table { min-width: 760px; border: 1px solid #ebeef5; }
.go-admin-prototype .el-table th,
.go-admin-prototype .el-table td { padding: 9px 10px; border-right: 1px solid #ebeef5; border-bottom-color: #ebeef5; }
.go-admin-prototype .el-table th { background: #f5f7fa; color: #909399; font-weight: 500; }
.go-admin-prototype .el-table tbody tr:nth-child(even) { background: #fafafa; }
.go-admin-prototype .el-table tbody tr:hover { background: #f5f7fa; }
.go-admin-prototype .checkbox-faux { width: 14px; height: 14px; display: inline-block; border: 1px solid #dcdfe6; border-radius: 2px; background: #fff; }
.go-admin-prototype .action-divider { height: 14px; margin: 0 3px; display: inline-block; border-left: 1px solid #dcdfe6; vertical-align: middle; }
.go-admin-prototype .el-tag { min-height: 24px; padding: 2px 8px; border: 1px solid currentColor; border-radius: 4px; font-weight: 400; }
.go-admin-prototype .el-tag::before { display: none; }
.go-admin-prototype .pagination-container { min-height: 48px; display: flex; align-items: center; justify-content: flex-end; gap: 6px; color: #606266; font-size: 12px; }
.go-admin-prototype .pagination-container button { min-width: 30px; height: 28px; border: 0; border-radius: 3px; background: #f4f4f5; color: #606266; }
.go-admin-prototype .pagination-container button.active { color: #fff; background: var(--ga-primary); }
.go-admin-prototype .pagination-container button:disabled { color: #c0c4cc; cursor: not-allowed; }
.go-admin-prototype .pagination-size { height: 28px; border: 1px solid #dcdfe6; border-radius: 4px; color: #606266; }
.go-admin-prototype .pagination-total { margin-right: 4px; }
.go-admin-prototype .description-list { display: grid; }
.go-admin-prototype .description-row { min-height: 43px; padding: 8px 0; display: grid; grid-template-columns: minmax(90px,.7fr) minmax(0,1.5fr) auto; align-items: center; gap: 10px; border-bottom: 1px solid #ebeef5; }
.go-admin-prototype .description-row:last-child { border-bottom: 0; }
.go-admin-prototype .description-row > span:first-child { color: #909399; font-size: 12px; }
.go-admin-prototype .description-row strong { font-size: 13px; font-weight: 500; }
.go-admin-prototype .sense-video-stage,
.go-admin-prototype .sense-zone-canvas { border-radius: 4px; }
.go-admin-prototype .el-overlay { background: rgba(0,0,0,.5); }
.go-admin-prototype .el-dialog { width: min(600px,100%); border-radius: 4px; }
.go-admin-prototype .el-dialog-header,
.go-admin-prototype .el-dialog-footer { padding: 16px 20px; }
.go-admin-prototype .el-dialog-body { padding: 20px; }
.go-admin-prototype .dialog-close { min-height: 32px; height: 32px; color: #909399; }
.go-admin-prototype .go-admin-shell.compact { grid-template-columns: 54px minmax(0,1fr); }
.go-admin-prototype .go-admin-shell.compact .sidebar-container { width: 54px; }
.go-admin-prototype .go-admin-shell.compact .brand-copy,
.go-admin-prototype .go-admin-shell.compact .el-menu-item span,
.go-admin-prototype .go-admin-shell.compact .menu-group span,
.go-admin-prototype .go-admin-shell.compact .menu-arrow,
.go-admin-prototype .go-admin-shell.compact .side-note { display: none; }
.go-admin-prototype .go-admin-shell.compact .sidebar-logo,
.go-admin-prototype .go-admin-shell.compact .menu-group,
.go-admin-prototype .go-admin-shell.compact .el-menu-item { padding-right: 0; padding-left: 0; justify-content: center; }
@media (max-width: 900px) {
.go-admin-prototype .go-admin-shell,
.go-admin-prototype .go-admin-shell.compact { display: block; }
.go-admin-prototype .sidebar-container,
.go-admin-prototype .go-admin-shell.compact .sidebar-container { position: fixed; top: 0; left: 0; z-index: 40; width: min(300px,88vw); height: 100vh; transform: translateX(-102%); }
.go-admin-prototype .sidebar-container.open,
.go-admin-prototype .go-admin-shell.compact .sidebar-container.open { transform: translateX(0); }
.go-admin-prototype .go-admin-shell.compact .brand-copy,
.go-admin-prototype .go-admin-shell.compact .el-menu-item span,
.go-admin-prototype .go-admin-shell.compact .menu-group span,
.go-admin-prototype .go-admin-shell.compact .menu-arrow,
.go-admin-prototype .go-admin-shell.compact .side-note { display: initial; }
.go-admin-prototype .go-admin-shell.compact .sidebar-logo,
.go-admin-prototype .go-admin-shell.compact .menu-group,
.go-admin-prototype .go-admin-shell.compact .el-menu-item { padding: 0 18px; justify-content: flex-start; }
.go-admin-prototype .desktop-collapse { display: none; }
.go-admin-prototype .mobile-menu { display: inline-flex; }
.go-admin-prototype .breadcrumb { max-width: 46vw; overflow: hidden; }
.go-admin-prototype .breadcrumb a,
.go-admin-prototype .breadcrumb span { display: none; }
.go-admin-prototype .app-main { padding: 12px; }
.go-admin-prototype .tags-view-container { height: 48px; padding-top: 8px; }
.go-admin-prototype .tags-view-item { height: 40px; }
.go-admin-prototype .el-button { min-height: 44px; height: 44px; }
.go-admin-prototype .el-input,
.go-admin-prototype .el-select { min-height: 44px; height: 44px; font-size: 16px; }
.go-admin-prototype .el-form-inline { align-items: stretch; }
.go-admin-prototype .el-form-inline .form-item { width: 100%; grid-template-columns: 82px minmax(0,1fr); }
.go-admin-prototype .el-form-inline .form-actions { margin-left: 90px; }
.go-admin-prototype .operation-bar { align-items: flex-start; flex-direction: column; padding: 9px 0; }
.go-admin-prototype .pagination-container button,
.go-admin-prototype .pagination-size { min-width: 40px; height: 40px; }
}
@media (max-width: 430px) {
.go-admin-prototype .desktop-only,
.go-admin-prototype .prototype-pill { display: none; }
.go-admin-prototype .topbar-actions { gap: 0; }
.go-admin-prototype .navbar-action,
.go-admin-prototype .avatar-button { min-width: 42px; padding: 0 8px; }
.go-admin-prototype .vertical-form .form-item { grid-template-columns: 1fr; gap: 5px; }
.go-admin-prototype .vertical-form .help { margin-left: 0; }
.go-admin-prototype .pagination-container { justify-content: flex-start; overflow-x: auto; }
}
+244
View File
@@ -0,0 +1,244 @@
:root {
color-scheme: light;
--bg: #f5f7fb;
--surface: #ffffff;
--surface-muted: #f8fafc;
--text: #172033;
--text-muted: #5d687c;
--border: #dce2ec;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--primary-soft: #e8f0ff;
--success: #16794a;
--success-soft: #e9f7ef;
--warning: #a15c00;
--warning-soft: #fff5df;
--danger: #c62828;
--danger-soft: #fff0f0;
--neutral-soft: #eef2f7;
--sidebar: #111b2e;
--sidebar-muted: #91a0b8;
--focus: #0b6bcb;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 16px;
--shadow-sm: 0 1px 2px rgba(18, 32, 56, 0.06);
--shadow-md: 0 8px 24px rgba(18, 32, 56, 0.09);
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--sidebar-width: 240px;
font-family: Inter, "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--bg); }
body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5; }
button, input, select, textarea { font: inherit; }
button, a, [role="button"] { touch-action: manipulation; }
button { cursor: pointer; }
a { color: inherit; }
svg { width: 20px; height: 20px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
:focus-visible { outline: 3px solid color-mix(in srgb, var(--focus) 42%, transparent); outline-offset: 2px; }
#main-content:focus { outline: none; }
.skip-link { position: fixed; left: 12px; top: -80px; z-index: 1000; background: var(--surface); padding: 10px 14px; border-radius: var(--radius-sm); box-shadow: var(--shadow-md); }
.skip-link:focus { top: 12px; }
.sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); }
.sidebar { position: sticky; top: 0; height: 100vh; overflow-y: auto; padding: 18px 14px; background: var(--sidebar); color: #fff; z-index: 40; }
.brand { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 4px 8px 18px; }
.brand-mark { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 9px; background: linear-gradient(135deg, #3b82f6, #1d4ed8); color: #fff; font-weight: 800; letter-spacing: -0.04em; }
.brand-name { font-size: 16px; font-weight: 700; letter-spacing: 0.01em; }
.brand-subtitle { display: block; color: var(--sidebar-muted); font-size: 11px; font-weight: 500; }
.side-label { margin: 12px 10px 6px; color: var(--sidebar-muted); font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.side-nav { display: grid; gap: 4px; }
.side-nav button, .side-nav a { width: 100%; min-height: 44px; border: 0; border-radius: 8px; padding: 10px 12px; display: flex; align-items: center; gap: 10px; background: transparent; color: #c8d1e0; text-align: left; text-decoration: none; transition: background 180ms ease, color 180ms ease; }
.side-nav button:hover, .side-nav a:hover { background: rgba(255,255,255,.07); color: #fff; }
.side-nav button.active, .side-nav a.active { background: #243653; color: #fff; box-shadow: inset 3px 0 0 #60a5fa; }
.side-note { margin: 18px 4px 0; padding: 12px; border: 1px solid rgba(255,255,255,.12); border-radius: 10px; background: rgba(255,255,255,.04); color: #c8d1e0; font-size: 12px; }
.side-note strong { color: #fff; display: block; margin-bottom: 4px; }
.main-column { min-width: 0; }
.topbar { position: sticky; top: 0; z-index: 30; min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 24px; border-bottom: 1px solid var(--border); background: rgba(255,255,255,.94); backdrop-filter: blur(10px); }
.topbar-left, .topbar-actions, .inline { display: flex; align-items: center; gap: 10px; }
.btn.mobile-menu { display: none; }
.page-eyebrow { color: var(--text-muted); font-size: 12px; }
.page-name { margin: 0; font-size: 18px; line-height: 1.25; }
.prototype-pill { display: inline-flex; align-items: center; gap: 6px; min-height: 28px; padding: 4px 9px; border: 1px solid #b8cdf8; border-radius: 999px; background: var(--primary-soft); color: #194fae; font-size: 12px; font-weight: 650; white-space: nowrap; }
.prototype-pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--primary); }
.content { width: min(1540px, 100%); margin: 0 auto; padding: 24px; }
.view { display: none; animation: fade-in 180ms ease-out; }
.view.active { display: block; }
@keyframes fade-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 20px; }
.page-header h1 { margin: 0 0 4px; font-size: clamp(22px, 2vw, 30px); line-height: 1.25; letter-spacing: -0.02em; }
.page-header p { margin: 0; color: var(--text-muted); max-width: 760px; }
.page-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.btn { min-height: 40px; padding: 8px 13px; border: 1px solid var(--border); border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; background: var(--surface); color: var(--text); font-weight: 650; text-decoration: none; transition: border-color 180ms ease, background 180ms ease, color 180ms ease, box-shadow 180ms ease; }
.btn:hover { border-color: #a9b5c8; background: #f8fafc; }
.btn-primary { border-color: var(--primary); background: var(--primary); color: #fff; }
.btn-primary:hover { border-color: var(--primary-hover); background: var(--primary-hover); }
.btn-danger { border-color: #e7b8b8; background: var(--danger-soft); color: var(--danger); }
.btn-quiet { border-color: transparent; background: transparent; color: var(--text-muted); }
.btn-icon { width: 42px; padding: 8px; }
.btn:disabled { cursor: not-allowed; opacity: .48; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.metric { min-width: 0; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow-sm); }
.metric-label { color: var(--text-muted); font-size: 12px; font-weight: 650; }
.metric-value { margin: 5px 0 3px; font-size: 26px; font-weight: 760; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
.metric-foot { display: flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 12px; }
.metric-foot strong { color: var(--success); }
.grid { display: grid; gap: 16px; }
.grid-2 { grid-template-columns: minmax(0, 1.6fr) minmax(280px, .9fr); }
.grid-even { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.card { min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow-sm); }
.card-header { min-height: 54px; padding: 13px 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); }
.card-header h2, .card-header h3 { margin: 0; font-size: 15px; }
.card-body { padding: 16px; }
.card-subtitle { margin: 2px 0 0; color: var(--text-muted); font-size: 12px; }
.stack { display: grid; gap: 12px; align-content: start; }
.status { min-height: 25px; padding: 3px 8px; display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; font-size: 12px; font-weight: 680; white-space: nowrap; }
.status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.status-success { background: var(--success-soft); color: var(--success); }
.status-warning { background: var(--warning-soft); color: var(--warning); }
.status-danger { background: var(--danger-soft); color: var(--danger); }
.status-neutral { background: var(--neutral-soft); color: #4c5a70; }
.toolbar { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; margin-bottom: 12px; }
.field { display: grid; gap: 5px; min-width: 170px; }
.field.grow { flex: 1 1 240px; }
.field label { font-size: 12px; font-weight: 680; color: #465268; }
.input, select.input, textarea.input { width: 100%; min-height: 42px; border: 1px solid #cfd7e4; border-radius: 8px; padding: 8px 11px; background: #fff; color: var(--text); }
.input:focus { border-color: var(--focus); outline: 3px solid color-mix(in srgb, var(--focus) 18%, transparent); outline-offset: 0; }
.help { color: var(--text-muted); font-size: 12px; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 680px; }
th, td { padding: 11px 12px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: middle; }
th { background: var(--surface-muted); color: #465268; font-size: 12px; font-weight: 720; }
td { font-variant-numeric: tabular-nums; }
tbody tr { transition: background 160ms ease; }
tbody tr:hover { background: #f8fbff; }
.table-title { font-weight: 680; }
.table-subtitle { display: block; margin-top: 2px; color: var(--text-muted); font-size: 12px; }
.empty-row { text-align: center; color: var(--text-muted); padding: 30px; }
.video-stage { position: relative; min-height: 360px; overflow: hidden; border-radius: 9px; background: radial-gradient(circle at 45% 38%, #31445d, #172337 55%, #0d1524); color: #dfe8f4; }
.video-stage::before { content: ""; position: absolute; inset: 18% 12%; border: 1px solid rgba(255,255,255,.12); background: linear-gradient(145deg, transparent 45%, rgba(255,255,255,.04)); }
.camera-caption { position: absolute; left: 14px; top: 14px; display: flex; gap: 8px; align-items: center; padding: 7px 10px; border-radius: 7px; background: rgba(7,13,24,.72); font-size: 12px; }
.video-time { position: absolute; right: 14px; bottom: 12px; font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; }
.zone-shape { position: absolute; inset: 24% 22% 18% 18%; border: 2px solid #60a5fa; background: rgba(37,99,235,.16); clip-path: polygon(8% 14%, 88% 0, 100% 72%, 42% 100%, 0 78%); }
.zone-label { position: absolute; left: 23%; top: 30%; padding: 4px 7px; border-radius: 5px; background: #1d4ed8; color: #fff; font-size: 11px; font-weight: 700; }
.activity-list { display: grid; gap: 10px; }
.activity-item { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: start; padding: 10px 0; border-bottom: 1px solid var(--border); }
.activity-item:last-child { border-bottom: 0; }
.activity-icon { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 8px; background: var(--primary-soft); color: var(--primary); }
.activity-title { font-weight: 680; }
.activity-meta { color: var(--text-muted); font-size: 12px; }
.activity-time { color: var(--text-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.pipeline { display: grid; grid-template-columns: repeat(6, minmax(112px, 1fr)); gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.pipeline-step { position: relative; min-height: 116px; padding: 13px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface); }
.pipeline-step:not(:last-child)::after { content: ""; position: absolute; right: -9px; top: 50%; width: 9px; border-top: 2px solid #9fb2ce; }
.pipeline-index { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 50%; background: var(--primary-soft); color: var(--primary); font-weight: 750; font-size: 12px; }
.pipeline-step h3 { margin: 9px 0 3px; font-size: 13px; }
.pipeline-step p { margin: 0; color: var(--text-muted); font-size: 12px; }
.pipeline-step.running { border-color: #8bb2ff; box-shadow: inset 0 0 0 1px #8bb2ff; }
.alert-row { display: grid; grid-template-columns: 5px minmax(0, 1fr) auto; gap: 12px; padding: 13px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface); cursor: pointer; }
.alert-row:hover, .alert-row.selected { border-color: #9db7e8; background: #f8fbff; }
.severity-bar { border-radius: 999px; background: var(--danger); }
.severity-bar.medium { background: var(--warning); }
.alert-title { font-weight: 720; }
.alert-meta { display: flex; flex-wrap: wrap; gap: 8px 14px; margin-top: 4px; color: var(--text-muted); font-size: 12px; }
.timeline { position: relative; display: grid; gap: 0; }
.timeline-item { position: relative; padding: 0 0 18px 26px; }
.timeline-item::before { content: ""; position: absolute; left: 6px; top: 7px; width: 9px; height: 9px; border-radius: 50%; background: var(--primary); box-shadow: 0 0 0 4px var(--primary-soft); }
.timeline-item:not(:last-child)::after { content: ""; position: absolute; left: 10px; top: 19px; bottom: 2px; border-left: 1px solid #bbcae0; }
.timeline-title { font-weight: 680; }
.timeline-meta { color: var(--text-muted); font-size: 12px; }
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 14px; overflow-x: auto; }
.tabs button { min-height: 42px; padding: 8px 12px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--text-muted); font-weight: 650; white-space: nowrap; }
.tabs button[aria-selected="true"] { border-bottom-color: var(--primary); color: var(--primary); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: none; align-items: center; justify-content: center; padding: 18px; background: rgba(7, 13, 24, .55); }
.modal-backdrop.open { display: flex; }
.modal { width: min(520px, 100%); max-height: min(760px, calc(100vh - 36px)); overflow-y: auto; border-radius: var(--radius-lg); background: var(--surface); box-shadow: 0 24px 70px rgba(0,0,0,.28); animation: modal-in 180ms ease-out; }
@keyframes modal-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.modal-header, .modal-footer { padding: 14px 18px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.modal-header { border-bottom: 1px solid var(--border); }
.modal-header h2 { margin: 0; font-size: 17px; }
.modal-body { padding: 18px; }
.modal-footer { border-top: 1px solid var(--border); justify-content: flex-end; }
.toast-region { position: fixed; right: 18px; bottom: 18px; z-index: 200; display: grid; gap: 8px; width: min(360px, calc(100vw - 36px)); }
.toast { padding: 12px 14px; border: 1px solid #b9c8dc; border-radius: 9px; background: #172033; color: #fff; box-shadow: var(--shadow-md); animation: toast-in 180ms ease-out; }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.prototype-home { min-height: 100vh; background: radial-gradient(circle at 80% 0%, #dbeafe, transparent 30%), var(--bg); }
.home-nav { min-height: 68px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px clamp(18px, 5vw, 72px); border-bottom: 1px solid var(--border); background: rgba(255,255,255,.88); }
.home-main { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: clamp(42px, 8vw, 92px) 0; }
.hero { max-width: 850px; }
.hero h1 { margin: 14px 0 16px; font-size: clamp(34px, 6vw, 64px); line-height: 1.05; letter-spacing: -0.045em; }
.hero p { max-width: 720px; margin: 0; color: var(--text-muted); font-size: clamp(16px, 2vw, 20px); }
.product-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 44px; }
.product-card { min-height: 300px; padding: 22px; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius-lg); background: rgba(255,255,255,.94); box-shadow: var(--shadow-sm); text-decoration: none; transition: border-color 180ms ease, box-shadow 180ms ease; }
.product-card:hover { border-color: #9db7e8; box-shadow: var(--shadow-md); }
.product-icon { width: 48px; height: 48px; display: grid; place-items: center; border-radius: 12px; background: var(--primary-soft); color: var(--primary); }
.product-icon svg { width: 26px; height: 26px; }
.product-card h2 { margin: 22px 0 8px; font-size: 23px; }
.product-card p { margin: 0; color: var(--text-muted); }
.product-card ul { padding-left: 18px; color: #465268; }
.card-link { margin-top: auto; display: flex; align-items: center; justify-content: space-between; color: var(--primary); font-weight: 720; }
.boundary-note { margin-top: 20px; padding: 14px 16px; border-left: 4px solid var(--warning); border-radius: 6px; background: var(--warning-soft); color: #714300; }
@media (max-width: 1080px) {
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-2, .grid-even { grid-template-columns: 1fr; }
.product-grid { grid-template-columns: 1fr; }
.product-card { min-height: auto; }
}
@media (max-width: 760px) {
.app-shell { display: block; }
.sidebar { position: fixed; left: 0; top: 0; width: min(300px, 88vw); transform: translateX(-102%); transition: transform 200ms ease; box-shadow: var(--shadow-md); }
.sidebar.open { transform: translateX(0); }
.btn.mobile-menu { display: inline-flex; }
.topbar { padding: 9px 14px; }
.topbar-actions .prototype-pill { display: none; }
.content { padding: 18px 14px 28px; }
.page-header { display: grid; }
.page-actions { justify-content: flex-start; }
.metric-grid { grid-template-columns: 1fr 1fr; }
.metric-value { font-size: 22px; }
.video-stage { min-height: 260px; }
.pipeline { grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: visible; }
.pipeline-step:not(:last-child)::after { display: none; }
.home-nav { padding: 10px 16px; }
.home-main { width: min(100% - 28px, 1180px); padding-top: 44px; }
}
@media (max-width: 430px) {
.metric-grid { grid-template-columns: 1fr; }
.topbar-actions .btn { display: none; }
.page-actions .btn { flex: 1 1 auto; }
.activity-item { grid-template-columns: auto minmax(0, 1fr); }
.activity-time { grid-column: 2; }
.pipeline { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
}
+54
View File
@@ -0,0 +1,54 @@
.bell-go-admin .sidebar { background: linear-gradient(180deg, #7c2d12 0%, #9a3412 44%, #431407 100%); }
.bell-go-admin .brand-mark { background: #ffedd5; color: #9a3412; }
.bell-go-admin .el-menu-item.active { background: rgba(255, 255, 255, 0.18); }
.bell-go-admin .nav-count { margin-left: auto; min-width: 20px; padding: 1px 6px; border-radius: 10px; background: rgba(255,255,255,.2); font-size: 12px; text-align: center; }
.bell-go-admin .compact-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.bell-workbench { grid-template-columns: minmax(290px, .8fr) minmax(440px, 1.2fr); align-items: start; }
.alert-workbench .card-body { padding: 0; }
.alert-list { display: grid; }
.alert-row { display: grid; grid-template-columns: 4px minmax(0, 1fr) auto; gap: 14px; width: 100%; min-height: 82px; padding: 15px 18px; border: 0; border-bottom: 1px solid var(--border); background: #fff; color: var(--text); font: inherit; text-align: left; cursor: pointer; }
.alert-row:hover { background: #fff7ed; }
.alert-row:focus-visible { outline: 2px solid #c2410c; outline-offset: -3px; }
.alert-row.selected { background: #fff7ed; box-shadow: inset 3px 0 #c2410c; }
.severity-bar { width: 4px; height: 48px; border-radius: 4px; background: var(--danger); }
.severity-bar.medium { background: var(--warning); }
.alert-title { display: block; margin-bottom: 7px; }
.alert-meta { display: flex; flex-wrap: wrap; gap: 6px 12px; color: var(--text-muted); font-size: 13px; }
.activity-time { color: var(--text-muted); font-size: 13px; }
.situation-summary { display: grid; gap: 12px; }
.situation-summary > div { display: grid; grid-template-columns: 92px 1fr; gap: 14px; }
.situation-summary span { color: var(--text-muted); }
.evidence-preview { display: flex; align-items: flex-end; justify-content: space-between; min-height: 170px; margin: 18px 0; padding: 18px; border-radius: 8px; background: linear-gradient(140deg, #172033, #506078); color: #fff; }
.evidence-preview span { color: #e2e8f0; }
.quick-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.primary-task { margin-top: 18px; padding: 15px; border-left: 4px solid #c2410c; border-radius: 4px; background: #fff7ed; }
.primary-task p { margin: 0 0 12px; line-height: 1.6; }
.technical-details { margin-top: 18px; border: 1px solid var(--border); border-radius: 6px; background: #f8fafc; }
.technical-details summary { padding: 11px 14px; cursor: pointer; color: #475569; font-weight: 600; }
.technical-details summary:focus-visible { outline: 2px solid #c2410c; outline-offset: 2px; }
.technical-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 20px; padding: 0 14px 14px; }
.technical-grid > div { display: grid; gap: 4px; }
.technical-grid span { color: var(--text-muted); font-size: 13px; }
.technical-grid strong { overflow-wrap: anywhere; }
.working-list { display: grid; gap: 10px; }
.working-item { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px; border: 1px solid var(--border); border-radius: 8px; }
.working-item p { margin: 5px 0 0; color: var(--text-muted); }
.result-options { display: grid; gap: 10px; margin: 0; padding: 0; border: 0; }
.result-options legend { margin-bottom: 4px; font-weight: 700; }
.result-card { display: flex; gap: 12px; min-height: 64px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; }
.result-card:has(input:checked) { border-color: #c2410c; background: #fff7ed; box-shadow: 0 0 0 1px #c2410c; }
.result-card input { margin-top: 4px; }
.result-card span { display: grid; gap: 3px; }
.result-card small { color: var(--text-muted); }
.field-error { color: #b91c1c; font-size: 13px; }
.dialog-subtitle { margin: 4px 0 0; color: var(--text-muted); font-size: 13px; }
.admin-records { margin-bottom: 14px; }
@media (max-width: 1100px) { .bell-workbench { grid-template-columns: 1fr; } }
@media (max-width: 767px) {
.bell-go-admin .compact-metrics { grid-template-columns: 1fr; }
.situation-summary > div { grid-template-columns: 1fr; gap: 3px; }
.technical-grid { grid-template-columns: 1fr; }
.working-item { align-items: stretch; flex-direction: column; }
.working-item .btn { width: 100%; }
.evidence-preview { align-items: flex-start; flex-direction: column; }
}
+76
View File
@@ -0,0 +1,76 @@
(function () {
"use strict";
const qs = (selector, root = document) => root.querySelector(selector);
const qsa = (selector, root = document) => [...root.querySelectorAll(selector)];
const toastRegion = qs("[data-toast-region]");
const toast = (message) => {
if (!toastRegion) return;
const item = document.createElement("div");
item.className = "toast";
item.setAttribute("role", "status");
item.textContent = message;
toastRegion.appendChild(item);
window.setTimeout(() => item.remove(), 3600);
};
const number = (selector) => Number(qs(selector)?.textContent || 0);
qsa("[data-select-alert]").forEach((row) => row.addEventListener("click", () => {
qs("[data-detail-title]").textContent = row.dataset.selectAlert;
qs("[data-detail-what]").textContent = row.dataset.selectAlert;
qs("[data-detail-location]").textContent = row.dataset.location;
qs("[data-detail-meta]").textContent = `${row.dataset.location} · ${row.dataset.time}`;
qs("[data-detail-suggestion]").textContent = row.dataset.suggestion;
const state = qs("[data-alert-state]");
state.textContent = row.dataset.risk;
state.className = `status ${row.dataset.risk === "高风险" ? "status-danger" : "status-warning"} el-tag`;
}));
let started = false;
const startButtons = qsa("[data-action='start-alert'], [data-action='table-start-alert']");
const startAlert = () => {
if (started) {
toast("该预警已由王值班员开始处理");
return;
}
started = true;
startButtons.forEach((button) => {
button.disabled = true;
button.textContent = "已确认";
});
const dashboardState = qs("[data-alert-state]");
dashboardState.textContent = "处理中";
dashboardState.className = "status status-warning el-tag";
const tableState = qs("[data-table-alert-state]");
tableState.textContent = "处理中";
tableState.className = "status status-warning el-tag";
qs("[data-table-owner]").textContent = "王值班员";
qs("[data-finish-from-table]").disabled = false;
qs("[data-pending-count]").textContent = String(Math.max(0, number("[data-pending-count]") - 1));
qs("[data-working-count]").textContent = String(number("[data-working-count]") + 1);
toast("预警已确认并由王值班员开始处理");
};
startButtons.forEach((button) => button.addEventListener("click", startAlert));
qsa("input[name='result']").forEach((input) => input.addEventListener("change", () => {
qs("[data-result-error]").hidden = true;
}));
qs("[data-action='finish-alert']")?.addEventListener("click", () => {
const selected = qs("input[name='result']:checked");
const error = qs("[data-result-error]");
if (!selected) {
error.hidden = false;
qs("input[name='result']")?.focus();
return;
}
const tableState = qs("[data-table-alert-state]");
tableState.textContent = "已关闭";
tableState.className = "status status-success el-tag";
qs("[data-finish-from-table]").disabled = true;
qs("[data-completed-count]").textContent = String(number("[data-completed-count]") + 1);
qs("[data-working-count]").textContent = String(Math.max(0, number("[data-working-count]") - 1));
qs("[data-close-modal]", qs("#finish-alert"))?.click();
toast(`预警已关闭,处理结果:${selected.value}`);
});
})();
+83
View File
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="YoVision Bell 普通预警系统后台 MVP 交互原型">
<title>YoVision Bell · 预警管理后台</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="../assets/styles.css">
<link rel="stylesheet" href="../assets/go-admin-ui.css">
<link rel="stylesheet" href="./bell.css">
</head>
<body class="go-admin-prototype bell-go-admin">
<svg class="sr-only" aria-hidden="true">
<symbol id="icon-menu" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></symbol>
<symbol id="icon-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></symbol>
<symbol id="icon-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-9M10 21h4"/></symbol>
<symbol id="icon-file" viewBox="0 0 24 24"><path d="M6 2h8l4 4v16H6zM14 2v5h5M9 13h6m-6 4h6"/></symbol>
<symbol id="icon-rule" viewBox="0 0 24 24"><path d="M4 6h10m4 0h2M4 12h3m4 0h9M4 18h7m4 0h5"/><circle cx="16" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="13" cy="18" r="2"/></symbol>
<symbol id="icon-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></symbol>
<symbol id="icon-refresh" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18 9a7 7 0 0 0-12-2L4 11m16 2-2 4a7 7 0 0 1-12-2"/></symbol>
<symbol id="icon-fullscreen" viewBox="0 0 24 24"><path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5"/></symbol>
<symbol id="icon-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></symbol>
<symbol id="icon-arrow" viewBox="0 0 24 24"><path d="M9 18l6-6-6-6"/></symbol>
</svg>
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="app-shell go-admin-shell" data-go-admin-shell>
<aside class="sidebar sidebar-container" data-sidebar aria-label="Bell 主导航">
<a class="brand sidebar-logo" href="../index.html"><span class="brand-mark">B</span><span class="brand-copy"><span class="brand-name">YoVision Bell</span><span class="brand-subtitle">预警管理系统</span></span></a>
<nav class="side-nav el-menu" aria-label="预警系统菜单">
<div class="menu-group"><svg aria-hidden="true"><use href="#icon-bell"></use></svg><span>预警中心</span><svg class="menu-arrow" aria-hidden="true"><use href="#icon-arrow"></use></svg></div>
<button class="active el-menu-item" data-view-target="dashboard" data-title="工作台"><svg aria-hidden="true"><use href="#icon-grid"></use></svg><span>工作台</span></button>
<button class="el-menu-item" data-view-target="alerts" data-title="预警管理"><svg aria-hidden="true"><use href="#icon-bell"></use></svg><span>预警管理</span><span class="nav-count">3</span></button>
<button class="el-menu-item" data-view-target="events" data-title="事件查询"><svg aria-hidden="true"><use href="#icon-file"></use></svg><span>事件查询</span></button>
<button class="el-menu-item" data-view-target="rules" data-title="规则配置"><svg aria-hidden="true"><use href="#icon-rule"></use></svg><span>规则配置</span></button>
</nav>
<div class="side-note"><strong>常用入口</strong><span>工作台查看最新情况;预警管理完成确认与关闭;事件和规则用于查询与配置。</span></div>
</aside>
<div class="main-column main-container">
<div class="fixed-header">
<header class="topbar navbar"><div class="topbar-left"><button class="navbar-action mobile-menu" type="button" data-mobile-menu aria-label="打开主导航" aria-expanded="false"><svg aria-hidden="true"><use href="#icon-menu"></use></svg></button><button class="navbar-action desktop-collapse" type="button" data-collapse-sidebar aria-label="折叠主导航" aria-expanded="true"><svg aria-hidden="true"><use href="#icon-menu"></use></svg></button><nav class="breadcrumb" aria-label="面包屑"><a href="../index.html">YoVision</a><span>/</span><span>Bell</span><span>/</span><strong data-page-name>工作台</strong></nav></div><div class="topbar-actions right-menu"><button class="navbar-action" type="button" data-toast="可按地点、预警或事件搜索" aria-label="全局搜索"><svg aria-hidden="true"><use href="#icon-search"></use></svg></button><button class="navbar-action desktop-only" type="button" data-toast="原型不切换浏览器全屏" aria-label="全屏"><svg aria-hidden="true"><use href="#icon-fullscreen"></use></svg></button><span class="prototype-pill">交互原型 · 非生产数据</span><button class="avatar-button" type="button" data-toast="当前用户:王值班员" aria-label="打开用户菜单"><span class="avatar"><svg aria-hidden="true"><use href="#icon-user"></use></svg></span><span class="desktop-only">王值班员</span></button></div></header>
<nav class="tags-view-container" aria-label="已访问页面"><button class="tags-view-item active" data-view-target="dashboard" data-title="工作台" aria-current="page">工作台</button><button class="tags-view-item" data-view-target="alerts" data-title="预警管理">预警管理 <span aria-hidden="true">×</span></button><button class="tags-view-item" data-view-target="events" data-title="事件查询">事件查询 <span aria-hidden="true">×</span></button><button class="tags-view-item" data-view-target="rules" data-title="规则配置">规则配置 <span aria-hidden="true">×</span></button></nav>
</div>
<main id="main-content" class="content app-main" tabindex="-1">
<section class="view active" data-view="dashboard">
<div class="page-header compact-header"><div><h1>工作台</h1><p>集中查看今日预警、处理进度和最新高风险情况。</p></div><div class="page-actions"><button class="btn el-button" data-view-target="alerts">进入预警管理</button></div></div>
<div class="metric-grid"><article class="metric el-card"><div class="metric-label">待确认预警</div><div class="metric-value" data-pending-count>3</div><div class="metric-foot"><span class="status status-danger el-tag">1 条高风险</span></div></article><article class="metric el-card"><div class="metric-label">处理中</div><div class="metric-value" data-working-count>2</div><div class="metric-foot"><span>均已明确处理人</span></div></article><article class="metric el-card"><div class="metric-label">今日已关闭</div><div class="metric-value" data-completed-count>18</div><div class="metric-foot"><strong>平均用时约 4 分钟</strong></div></article><article class="metric el-card"><div class="metric-label">今日事件</div><div class="metric-value">29</div><div class="metric-foot"><span>已生成 23 条预警</span></div></article></div>
<div class="grid grid-2 bell-workbench">
<article class="card el-card alert-workbench"><header class="card-header"><div><h2>最新预警</h2><p class="card-subtitle">按风险和时间排序</p></div><button class="btn btn-quiet el-button-link" data-view-target="alerts">查看全部</button></header><div class="card-body alert-list" aria-label="最新预警列表"><button class="alert-row selected" type="button" data-select-alert="危险区域有人进入" data-location="北围墙 01" data-time="今天 10:24" data-risk="高风险" data-suggestion="先查看现场;如无法确认,请联系校内保安巡查。"><span class="severity-bar"></span><span><strong class="alert-title">危险区域有人进入</strong><span class="alert-meta"><span>北围墙 01</span><span>待确认</span><span>刚刚</span></span></span><time class="activity-time">10:24</time></button><button class="alert-row" type="button" data-select-alert="有人反向越过警戒线" data-location="宿舍东门 02" data-time="今天 10:19" data-risk="中风险" data-suggestion="查看现场是否有人逆向进入;确认安全后记录处理结果。"><span class="severity-bar medium"></span><span><strong class="alert-title">有人反向越过警戒线</strong><span class="alert-meta"><span>宿舍东门 02</span><span>处理中</span><span>5 分钟前</span></span></span><time class="activity-time">10:19</time></button><button class="alert-row" type="button" data-select-alert="危险区域有人长时间停留" data-location="食堂后门 01" data-time="今天 10:11" data-risk="中风险" data-suggestion="查看现场并确认停留原因;无法判断时请安排人员巡查。"><span class="severity-bar medium"></span><span><strong class="alert-title">危险区域有人长时间停留</strong><span class="alert-meta"><span>食堂后门 01</span><span>待确认</span><span>13 分钟前</span></span></span><time class="activity-time">10:11</time></button></div></article>
<article class="card el-card alert-detail"><header class="card-header"><div><h2 data-detail-title>危险区域有人进入</h2><p class="card-subtitle" data-detail-meta>北围墙 01 · 今天 10:24</p></div><span class="status status-danger el-tag" data-alert-state>高风险</span></header><div class="card-body"><div class="situation-summary"><div><span>预警内容</span><strong data-detail-what>危险区域有人进入</strong></div><div><span>发生地点</span><strong data-detail-location>北围墙 01</strong></div><div><span>处理建议</span><strong data-detail-suggestion>先查看现场;如无法确认,请联系校内保安巡查。</strong></div></div><div class="evidence-preview" role="img" aria-label="北围墙预警现场的模拟画面"><span>北围墙 01 · 10:23:55</span><strong>现场画面</strong></div><div class="quick-actions"><button class="btn el-button" data-toast="已打开现场画面(原型演示)">查看现场</button><button class="btn el-button el-button-primary" data-action="start-alert">确认并开始处理</button></div><details class="technical-details"><summary>技术详情</summary><div class="technical-grid"><div><span>预警编号</span><strong>BEL-A-20260812-0031</strong></div><div><span>关联事件</span><strong>2 条不可变事件</strong></div><div><span>规则版本</span><strong>校园高危区域 · v4</strong></div><div><span>状态映射</span><strong>Alert new → ack → close</strong></div></div></details></div></article>
</div>
</section>
<section class="view" data-view="alerts" hidden>
<div class="page-header compact-header"><div><h1>预警管理</h1><p>统一查询和处理待确认、处理中及已关闭的预警。</p></div></div>
<article class="card el-card box-card"><div class="card-body"><form class="search-form el-form el-form-inline" data-go-admin-query data-query-input="#alert-filter"><div class="form-item"><label for="alert-filter">预警关键字</label><input id="alert-filter" class="input el-input" data-filter-input="#alert-table" placeholder="预警内容、地点或处理人"></div><div class="form-item"><label for="alert-status">预警状态</label><select id="alert-status" class="input el-select"><option>全部状态</option><option>待确认</option><option>处理中</option><option>已关闭</option></select></div><div class="form-item"><label for="alert-level">风险等级</label><select id="alert-level" class="input el-select"><option>全部等级</option><option>高风险</option><option>中风险</option></select></div><div class="form-actions"><button class="btn el-button el-button-primary" type="submit"><svg aria-hidden="true"><use href="#icon-search"></use></svg>搜索</button><button class="btn el-button" type="reset"><svg aria-hidden="true"><use href="#icon-refresh"></use></svg>重置</button></div></form><div class="operation-bar"><div class="operation-actions"><button class="btn el-button" disabled title="请先选择预警">批量导出</button></div><span class="permission-hint">确认、关闭等操作会记录处理人和时间</span></div>
<div class="table-wrap"><table id="alert-table" class="el-table"><thead><tr><th>预警内容</th><th>地点</th><th>风险</th><th>状态</th><th>处理人</th><th>发生时间</th><th>操作</th></tr></thead><tbody data-alert-body><tr data-current-alert><td><span class="table-title">危险区域有人进入</span></td><td>北围墙 01</td><td><span class="status status-danger el-tag">高风险</span></td><td><span class="status status-danger el-tag" data-table-alert-state>待确认</span></td><td data-table-owner>—</td><td>10:24</td><td><button class="btn btn-quiet el-button-link" data-action="table-start-alert">确认</button><button class="btn btn-quiet el-button-link" data-open-modal="finish-alert" data-finish-from-table disabled>关闭</button></td></tr><tr><td><span class="table-title">有人反向越过警戒线</span></td><td>宿舍东门 02</td><td><span class="status status-warning el-tag">中风险</span></td><td><span class="status status-warning el-tag">处理中</span></td><td>李值班员</td><td>10:19</td><td><button class="btn btn-quiet el-button-link" data-toast="该预警已由李值班员处理">查看</button></td></tr><tr><td><span class="table-title">危险区域有人长时间停留</span></td><td>食堂后门 01</td><td><span class="status status-warning el-tag">中风险</span></td><td><span class="status status-success el-tag">已关闭</span></td><td>保安室</td><td>10:11</td><td><button class="btn btn-quiet el-button-link" data-toast="已打开预警详情和处理记录">查看</button></td></tr></tbody></table></div><nav class="pagination-container el-pagination" aria-label="预警列表分页"><span class="pagination-total">共 23 条</span><button type="button" disabled aria-label="上一页">‹</button><button type="button" class="active" aria-current="page">1</button><button type="button" aria-label="下一页">›</button><select class="pagination-size" aria-label="每页条数"><option>10 条/页</option><option>20 条/页</option></select></nav>
</div></article>
</section>
<section class="view" data-view="events" hidden>
<div class="page-header compact-header"><div><h1>事件查询</h1><p>查询系统接收的原始事件及其关联预警;原始内容接收后不可修改。</p></div></div>
<article class="card el-card box-card"><div class="card-body"><form class="search-form el-form el-form-inline" data-go-admin-query data-query-input="#event-filter"><div class="form-item"><label for="event-filter">事件关键字</label><input id="event-filter" class="input el-input" data-filter-input="#event-table" placeholder="事件类型、来源或地点"></div><div class="form-item"><label for="event-link">关联状态</label><select id="event-link" class="input el-select"><option>全部</option><option>已生成预警</option><option>未生成预警</option></select></div><div class="form-actions"><button class="btn el-button el-button-primary" type="submit"><svg aria-hidden="true"><use href="#icon-search"></use></svg>搜索</button><button class="btn el-button" type="reset"><svg aria-hidden="true"><use href="#icon-refresh"></use></svg>重置</button></div></form><div class="table-wrap"><table id="event-table" class="el-table"><thead><tr><th>事件类型</th><th>来源</th><th>地点</th><th>接收时间</th><th>关联预警</th><th>操作</th></tr></thead><tbody><tr><td><span class="table-title">危险区域进入</span></td><td>视频分析服务</td><td>北围墙 01</td><td>10:23:55</td><td>BEL-A-0031</td><td><button class="btn btn-quiet el-button-link" data-toast="已打开事件与预警关联详情">查看</button></td></tr><tr><td><span class="table-title">方向越线</span></td><td>视频分析服务</td><td>宿舍东门 02</td><td>10:19:12</td><td>BEL-A-0029</td><td><button class="btn btn-quiet el-button-link" data-toast="已打开事件与预警关联详情">查看</button></td></tr><tr><td><span class="table-title">设备离线</span></td><td>第三方设备平台</td><td>体育馆北门</td><td>10:02:31</td><td>未生成</td><td><button class="btn btn-quiet el-button-link" data-toast="已打开原始事件详情">查看</button></td></tr></tbody></table></div><details class="technical-details"><summary>技术接收信息</summary><div class="technical-grid"><div><span>幂等依据</span><strong>生产者身份 + 来源事件编号</strong></div><div><span>重复接收</span><strong>6 条重复收据,未重复创建预警</strong></div></div></details><nav class="pagination-container el-pagination" aria-label="事件列表分页"><span class="pagination-total">共 29 条</span><button type="button" disabled aria-label="上一页">‹</button><button type="button" class="active" aria-current="page">1</button><button type="button" aria-label="下一页">›</button></nav></div></article>
</section>
<section class="view" data-view="rules" hidden>
<div class="page-header compact-header"><div><h1>规则配置</h1><p>配置哪些事件需要生成预警及其风险等级;已发布版本不会被直接修改。</p></div><div class="page-actions"><button class="btn el-button el-button-primary" data-toast="已创建规则草稿,不影响当前生效版本" title="权限:bell:rule:add">新增规则</button></div></div>
<article class="card el-card box-card"><div class="card-body"><form class="search-form el-form el-form-inline"><div class="form-item"><label for="rule-filter">规则名称</label><input id="rule-filter" class="input el-input" placeholder="输入规则名称"></div><div class="form-item"><label for="rule-status">状态</label><select id="rule-status" class="input el-select"><option>全部状态</option><option>已启用</option><option>已停用</option></select></div><div class="form-actions"><button class="btn el-button el-button-primary" type="button" data-toast="已筛选规则">搜索</button><button class="btn el-button" type="reset">重置</button></div></form><div class="table-wrap"><table class="el-table"><thead><tr><th>规则名称</th><th>事件类型</th><th>风险等级</th><th>当前版本</th><th>状态</th><th>更新时间</th><th>操作</th></tr></thead><tbody><tr><td><span class="table-title">校园高危区域</span></td><td>危险区域进入</td><td><span class="status status-danger el-tag">高风险</span></td><td>v4</td><td><span class="status status-success el-tag">已启用</span></td><td>今天 09:20</td><td><button class="btn btn-quiet el-button-link" data-toast="已打开规则草稿编辑页" title="权限:bell:rule:edit">编辑</button></td></tr><tr><td><span class="table-title">反向越过警戒线</span></td><td>方向越线</td><td><span class="status status-warning el-tag">中风险</span></td><td>v2</td><td><span class="status status-success el-tag">已启用</span></td><td>昨天 16:08</td><td><button class="btn btn-quiet el-button-link" data-toast="已打开规则草稿编辑页" title="权限:bell:rule:edit">编辑</button></td></tr></tbody></table></div><div class="boundary-note">规则编辑和发布受角色权限控制;自动升级、通知和排班将在后续版本提供。</div></div></article>
</section>
</main>
</div>
</div>
<div class="modal-backdrop el-overlay" id="finish-alert" aria-hidden="true"><section class="modal el-dialog" role="dialog" aria-modal="true" aria-labelledby="finish-title"><header class="modal-header el-dialog-header"><div><h2 id="finish-title">关闭预警</h2><p class="dialog-subtitle">北围墙 01 · 危险区域有人进入</p></div><button class="btn btn-icon btn-quiet dialog-close" data-close-modal aria-label="关闭预警窗口">×</button></header><div class="modal-body el-dialog-body"><form class="el-form vertical-form"><fieldset class="result-options"><legend>处理结果 <span aria-hidden="true">*</span></legend><label class="result-card"><input type="radio" name="result" value="确认有危险"><span><strong>确认有危险</strong><small>现场确有危险,需要保留为真实预警</small></span></label><label class="result-card"><input type="radio" name="result" value="误报"><span><strong>误报</strong><small>识别有误,现场没有相关情况</small></span></label><label class="result-card"><input type="radio" name="result" value="现场正常"><span><strong>现场正常</strong><small>情况真实发生,但已确认没有危险</small></span></label><label class="result-card"><input type="radio" name="result" value="无法确认"><span><strong>无法确认</strong><small>信息不足,需要后续复核</small></span></label><span class="field-error" data-result-error role="alert" hidden>请选择处理结果后再关闭预警。</span></fieldset><div class="form-item"><label for="result-note">处理说明(选填)</label><textarea id="result-note" class="input el-input" rows="3" placeholder="例如:已联系保安到现场确认"></textarea></div></form></div><footer class="modal-footer el-dialog-footer"><button class="btn el-button" data-close-modal>取消</button><button class="btn el-button el-button-primary" type="button" data-action="finish-alert">确认结果并关闭</button></footer></section></div>
<div class="toast-region" data-toast-region aria-live="polite"></div>
<script src="../assets/app.js"></script>
<script src="../assets/go-admin-shell.js"></script>
<script src="./bell.js"></script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="YoVision Brain 当前 MVP 内部流程原型">
<title>YoVision Brain · 当前 MVP 内部流程原型</title>
<link rel="stylesheet" href="../assets/styles.css">
</head>
<body>
<svg class="sr-only" aria-hidden="true">
<symbol id="icon-menu" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></symbol>
<symbol id="icon-cpu" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 1v3m6-3v3M9 20v3m6-3v3M1 9h3m-3 6h3m16-6h3m-3 6h3M9 9h6v6H9z"/></symbol>
<symbol id="icon-input" viewBox="0 0 24 24"><path d="M4 12h13m-5-5l5 5-5 5M20 5v14"/></symbol>
<symbol id="icon-activity" viewBox="0 0 24 24"><path d="M3 12h4l2-7 4 14 2-7h6"/></symbol>
<symbol id="icon-file" viewBox="0 0 24 24"><path d="M6 2h8l4 4v16H6zM14 2v5h5M9 13h6m-6 4h6"/></symbol>
</svg>
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="app-shell">
<aside class="sidebar" data-sidebar aria-label="Brain 内部导航">
<a class="brand" href="../index.html" style="text-decoration:none"><span class="brand-mark">B</span><span><span class="brand-name">YoVision Brain</span><span class="brand-subtitle">匿名推理交付单元</span></span></a>
<div class="side-label">内部验收</div>
<nav class="side-nav">
<button class="active" data-view-target="pipeline" data-title="推理流水线"><svg aria-hidden="true"><use href="#icon-cpu"></use></svg>推理流水线</button>
<button data-view-target="inputs" data-title="输入夹具"><svg aria-hidden="true"><use href="#icon-input"></use></svg>输入夹具</button>
<button data-view-target="events" data-title="项目内事件"><svg aria-hidden="true"><use href="#icon-activity"></use></svg>项目内事件</button>
</nav>
<div class="side-note"><strong>不是客户管理后台</strong>Brain 无交互式账户,不持有 Alert、ack 或通知状态。本页仅用于开发、演示与验收内部流水线。</div>
</aside>
<div class="main-column">
<header class="topbar"><div class="topbar-left"><button class="btn btn-icon mobile-menu" data-mobile-menu aria-label="打开内部导航" aria-expanded="false"><svg aria-hidden="true"><use href="#icon-menu"></use></svg></button><div><div class="page-eyebrow">synthetic-lab · CPU 降级模式</div><h2 class="page-name" data-page-name>推理流水线</h2></div></div><div class="topbar-actions"><span class="prototype-pill">内部流程原型</span><a class="btn" href="../index.html">返回原型入口</a></div></header>
<main id="main-content" class="content" tabindex="-1">
<section class="view active" data-view="pipeline">
<div class="page-header"><div><h1>单路匿名推理流水线</h1><p>使用合成视频和项目内配置独立验收,不依赖 Sense/Bell 在线,也不输出正式跨项目契约。</p></div><div class="page-actions"><button class="btn" data-toast="流水线已暂停;现有项目内事件不受影响">暂停</button><button class="btn btn-primary" data-action="run-frame">处理下一帧</button></div></div>
<div class="metric-grid"><article class="metric"><div class="metric-label">已处理帧</div><div class="metric-value" data-frame-count>12,842</div><div class="metric-foot"><strong>24.8 FPS</strong><span>· 单路</span></div></article><article class="metric"><div class="metric-label">端到端延迟</div><div class="metric-value">82 ms</div><div class="metric-foot"><strong>实验室值</strong><span>· 非容量承诺</span></div></article><article class="metric"><div class="metric-label">当前目标</div><div class="metric-value">3</div><div class="metric-foot"><span>匿名 track ID</span></div></article><article class="metric"><div class="metric-label">候选事件</div><div class="metric-value">7</div><div class="metric-foot"><span class="status status-neutral">项目内</span></div></article></div>
<article class="card"><header class="card-header"><div><h2>执行链</h2><p class="card-subtitle">每层均可替换并独立测试</p></div><span class="status status-success">运行中</span></header><div class="card-body"><div class="pipeline" aria-label="推理流水线六个步骤">
<div class="pipeline-step running"><span class="pipeline-index">1</span><h3>输入适配</h3><p>synthetic-campus.mp4<br>1920×1080 · 25 FPS</p></div>
<div class="pipeline-step running"><span class="pipeline-index">2</span><h3>视频解码</h3><p>software decode<br>队列 2 帧</p></div>
<div class="pipeline-step running"><span class="pipeline-index">3</span><h3>匿名检测</h3><p>person · 3 boxes<br>模型 demo-v1</p></div>
<div class="pipeline-step running"><span class="pipeline-index">4</span><h3>单路跟踪</h3><p>3 active tracks<br>无身份信息</p></div>
<div class="pipeline-step running"><span class="pipeline-index">5</span><h3>区域判定</h3><p>危险区域 + 方向线<br>规则 v3</p></div>
<div class="pipeline-step"><span class="pipeline-index">6</span><h3>项目内事件</h3><p>本地 JSON<br>未绑定 Bell 契约</p></div>
</div></div></article>
<div class="grid grid-2" style="margin-top:16px"><article class="card"><header class="card-header"><div><h2>模拟画面</h2><p class="card-subtitle">不包含真实未成年人影像</p></div></header><div class="card-body"><div class="video-stage" role="img" aria-label="合成校园画面,显示匿名跟踪框和方向警戒线"><div class="camera-caption"><span class="status status-success">合成输入</span>synthetic-camera-01</div><div class="zone-shape"></div><span class="zone-label">track-0042 · person</span><div style="position:absolute;left:62%;top:34%;width:72px;height:132px;border:2px solid #f59e0b"></div><span class="video-time">frame 12,842</span></div></div></article><article class="card"><header class="card-header"><div><h2>最近候选事实</h2><p class="card-subtitle">正式事件结构等待协同工单</p></div></header><div class="card-body activity-list" data-brain-events><div class="activity-item"><div class="activity-icon"><svg aria-hidden="true"><use href="#icon-activity"></use></svg></div><div><div class="activity-title">危险区域进入候选</div><div class="activity-meta">track-0042 · 置信度 0.88 · rule-v3</div></div><div class="activity-time">10:20:16</div></div><div class="activity-item"><div class="activity-icon"><svg aria-hidden="true"><use href="#icon-activity"></use></svg></div><div><div class="activity-title">方向越线候选</div><div class="activity-meta">track-0038 · east-to-west · rule-v3</div></div><div class="activity-time">10:19:42</div></div></div></article></div>
</section>
<section class="view" data-view="inputs" hidden><div class="page-header"><div><h1>输入夹具</h1><p>本地视频、合成帧和项目内区域配置,确保 Brain 可脱离 Sense 独立测试。</p></div><div class="page-actions"><button class="btn btn-primary" data-toast="已校验夹具:视频、Profile 与区域坐标匹配">校验全部夹具</button></div></div><article class="card"><div class="table-wrap"><table><thead><tr><th>夹具</th><th>类型</th><th>Profile</th><th>区域配置</th><th>状态</th></tr></thead><tbody><tr><td><span class="table-title">synthetic-campus.mp4</span><span class="table-subtitle">无真实人物数据</span></td><td>合成视频</td><td>1920×1080 / 25 FPS</td><td>zone-v3.json</td><td><span class="status status-success">可用</span></td></tr><tr><td><span class="table-title">cross-line-east.jsonl</span><span class="table-subtitle">可重复确定性输入</span></td><td>合成帧序列</td><td>1280×720 / 15 FPS</td><td>line-v2.json</td><td><span class="status status-success">可用</span></td></tr><tr><td><span class="table-title">local-sample.mp4</span><span class="table-subtitle">仓库外路径引用</span></td><td>本地视频</td><td>尚未探测</td><td>无</td><td><span class="status status-warning">待配置</span></td></tr></tbody></table></div></article></section>
<section class="view" data-view="events" hidden><div class="page-header"><div><h1>项目内匿名事件</h1><p>仅用于 Brain 独立纵切验收。它不是 `contracts/` 中的标准事件,也不会直接投递 Bell。</p></div><div class="page-actions"><button class="btn" data-toast="已导出 7 条脱敏项目内事件">导出测试结果</button></div></div><div class="boundary-note" style="margin:0 0 16px"><strong>契约门禁:</strong>禁止在本目录复制一份临时 Bell 事件协议。正式 producer/source event ID、设备逻辑引用和证据字段由后续协同工单冻结。</div><article class="card"><div class="table-wrap"><table><thead><tr><th>本地事件 ID</th><th>规则</th><th>匿名观测</th><th>规则/模型版本</th><th>结果</th></tr></thead><tbody><tr><td>local-0007</td><td>危险区域进入</td><td>track-0042</td><td>rule-v3 / demo-v1</td><td><span class="status status-success">已生成</span></td></tr><tr><td>local-0006</td><td>方向越线</td><td>track-0038</td><td>rule-v3 / demo-v1</td><td><span class="status status-success">已生成</span></td></tr><tr><td>local-0005</td><td>危险区域停留</td><td>track-0031</td><td>rule-v2 / demo-v1</td><td><span class="status status-neutral">已抑制</span></td></tr></tbody></table></div></article></section>
</main>
</div>
</div>
<div class="toast-region" data-toast-region aria-live="polite"></div>
<script src="../assets/app.js"></script>
</body>
</html>
+227
View File
@@ -0,0 +1,227 @@
# Design System Master File
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
> If that file exists, its rules **override** this Master file.
> If not, strictly follow the rules below.
---
**Project:** YoVision
**Generated:** 2026-08-12 10:10:03
**Category:** Analytics Dashboard
**Design Dials:** Variance 4/10 (Balanced / Modern) | Motion 3/10 (Subtle) | Density 8/10 (Dense / Dashboard)
---
## Global Rules
### Color Palette
| Role | Hex | CSS Variable |
|------|-----|--------------|
| Primary | `#DC2626` | `--color-primary` |
| On Primary | `#FFFFFF` | `--color-on-primary` |
| Secondary | `#EF4444` | `--color-secondary` |
| Accent/CTA | `#2563EB` | `--color-accent` |
| Background | `#FFF1F2` | `--color-background` |
| Foreground | `#0F172A` | `--color-foreground` |
| Muted | `#FCF1F1` | `--color-muted` |
| Border | `#FAE4E4` | `--color-border` |
| Destructive | `#DC2626` | `--color-destructive` |
| Ring | `#DC2626` | `--color-ring` |
**Color Notes:** Alert red + safety blue
### Typography
- **Heading Font:** Inter
- **Body Font:** Inter
- **Mood:** minimal, clean, swiss, functional, neutral, professional
- **Google Fonts:** [Inter + Inter](https://fonts.google.com/share?selection.family=Inter:wght@300;400;500;600;700)
**CSS Import:**
```css
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
```
### Spacing Variables
*Density: 8/10 — Dense / Dashboard*
| Token | Value | Usage |
|-------|-------|-------|
| `--space-xs` | `2px` / `0.125rem` | Tight gaps |
| `--space-sm` | `4px` / `0.25rem` | Icon gaps, inline spacing |
| `--space-md` | `8px` / `0.5rem` | Standard padding |
| `--space-lg` | `12px` / `0.75rem` | Section padding |
| `--space-xl` | `16px` / `1rem` | Large gaps |
| `--space-2xl` | `24px` / `1.5rem` | Section margins |
| `--space-3xl` | `32px` / `2rem` | Hero padding |
### Shadow Depths
| Level | Value | Usage |
|-------|-------|-------|
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
---
## Component Specs
### Buttons
```css
/* Primary Button */
.btn-primary {
background: #2563EB;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Secondary Button */
.btn-secondary {
background: transparent;
color: #DC2626;
border: 2px solid #DC2626;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
```
### Cards
```css
.card {
background: #FFF1F2;
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow-md);
transition: all 200ms ease;
cursor: pointer;
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
```
### Inputs
```css
.input {
padding: 12px 16px;
border: 1px solid #E2E8F0;
border-radius: 8px;
font-size: 16px;
transition: border-color 200ms ease;
}
.input:focus {
border-color: #DC2626;
outline: none;
box-shadow: 0 0 0 3px #DC262620;
}
```
### Modals
```css
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-xl);
max-width: 500px;
width: 90%;
}
```
---
## Style Guidelines
**Style:** Data-Dense Dashboard
**Keywords:** Multiple charts/widgets, data tables, KPI cards, minimal padding, grid layout, space-efficient, maximum data visibility
**Best For:** Business intelligence dashboards, financial analytics, enterprise reporting, operational dashboards, data warehousing
**Key Effects:** Hover tooltips, chart zoom on click, row highlighting on hover, smooth filter animations, data loading spinners
### Page Pattern
**Pattern Name:** Real-Time / Operations Landing
- **Conversion Strategy:** For ops/security/iot products. Demo or sandbox link. Trust signals.
- **CTA Placement:** Primary CTA in nav + After metrics
- **Section Order:** 1. Hero (product + live preview or status), 2. Key metrics/indicators, 3. How it works, 4. CTA (Start trial / Contact)
---
## Motion
**Page Transition** (Subtle) — Trigger: route change | Duration: 200-300ms | Easing: `power1.inOut`
```js
gsap.to(main, { opacity: 0, duration: 0.2, onComplete: () => { navigate(); gsap.fromTo(main, { opacity: 0 }, { opacity: 1, duration: 0.2 }); } });
```
**Framework notes:** Pair with the router's transition hooks (Next.js App Router transitions, React Router's useNavigate, Vue Router's beforeEach/afterEach)
- ✅ Preload the destination route's critical assets before the exit tween finishes
- ❌ Don't block navigation on animation; cap exit duration at ~250ms so the app never feels unresponsive
- ⚡ Exit animation should always resolve faster than entrance (asymmetric timing) so back/forward feels snappy
---
## Anti-Patterns (Do NOT Use)
- ❌ Ornate design
- ❌ No filtering
### Additional Forbidden Patterns
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
- ❌ **Instant state changes** — Always use transitions (150-300ms)
- ❌ **Invisible focus states** — Focus states must be visible for a11y
---
## Pre-Delivery Checklist
Before delivering any UI code, verify:
- [ ] No emojis used as icons (use SVG instead)
- [ ] All icons from consistent icon set (Heroicons/Lucide)
- [ ] `cursor-pointer` on all clickable elements
- [ ] Hover states with smooth transitions (150-300ms)
- [ ] Light mode: text contrast 4.5:1 minimum
- [ ] Focus states visible for keyboard navigation
- [ ] `prefers-reduced-motion` respected
- [ ] Responsive: 375px, 768px, 1024px, 1440px
- [ ] No content hidden behind fixed navbars
- [ ] No horizontal scroll on mobile
+58
View File
@@ -0,0 +1,58 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="YoVision Sense、Brain、Bell 当前 MVP 交互原型入口">
<title>YoVision · 当前 MVP 交互原型</title>
<link rel="stylesheet" href="assets/styles.css">
</head>
<body class="prototype-home">
<svg class="sr-only" aria-hidden="true">
<symbol id="icon-camera" viewBox="0 0 24 24"><path d="M15 10l4.5-2.5v9L15 14z"/><rect x="3" y="6" width="12" height="12" rx="2"/></symbol>
<symbol id="icon-cpu" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 1v3m6-3v3M9 20v3m6-3v3M1 9h3m-3 6h3m16-6h3m-3 6h3M9 9h6v6H9z"/></symbol>
<symbol id="icon-bell" viewBox="0 0 24 24"><path d="M18 8a6 6 0 00-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/></symbol>
<symbol id="icon-arrow" viewBox="0 0 24 24"><path d="M5 12h14m-5-5l5 5-5 5"/></symbol>
</svg>
<a class="skip-link" href="#main-content">跳到主要内容</a>
<header class="home-nav">
<div class="brand" aria-label="YoVision">
<span class="brand-mark">YV</span>
<span><span class="brand-name" style="color:var(--text)">YoVision</span><span class="brand-subtitle" style="color:var(--text-muted)">School Safety Suite</span></span>
</div>
<span class="prototype-pill">MVP #8 · 交互原型</span>
</header>
<main id="main-content" class="home-main" tabindex="-1">
<section class="hero">
<span class="status status-neutral">三项目 · 两个独立产品</span>
<h1>先独立跑通,<br>再可靠协作。</h1>
<p>这组原型对应当前 YoVision MVP:Sense 负责设备与视频感知管理,Brain 负责无界面匿名推理,Bell 负责独立预警处置。页面中的数据均为演示数据。</p>
</section>
<section class="product-grid" aria-label="选择产品原型">
<a class="product-card" href="sense/index.html">
<span class="product-icon"><svg aria-hidden="true"><use href="#icon-camera"></use></svg></span>
<h2>Sense</h2>
<p>智能 NVR 与视频感知管理面</p>
<ul><li>设备与 ONVIF/RTSP</li><li>MediaMTX 与实时监看</li><li>区域和方向警戒线</li></ul>
<span class="card-link">打开 Sense 原型 <svg aria-hidden="true"><use href="#icon-arrow"></use></svg></span>
</a>
<a class="product-card" href="brain/index.html">
<span class="product-icon"><svg aria-hidden="true"><use href="#icon-cpu"></use></svg></span>
<h2>Brain</h2>
<p>无客户界面的匿名推理交付单元</p>
<ul><li>合成/本地输入</li><li>解码、检测与跟踪</li><li>区域/越线项目内事件</li></ul>
<span class="card-link">打开内部流程原型 <svg aria-hidden="true"><use href="#icon-arrow"></use></svg></span>
</a>
<a class="product-card" href="bell/index.html">
<span class="product-icon"><svg aria-hidden="true"><use href="#icon-bell"></use></svg></span>
<h2>Bell</h2>
<p>独立事件预警与处置产品</p>
<ul><li>合成事件与不可变 Event</li><li>规则匹配与 Alert</li><li>并发 ack、close 与审计</li></ul>
<span class="card-link">打开 Bell 原型 <svg aria-hidden="true"><use href="#icon-arrow"></use></svg></span>
</a>
</section>
<aside class="boundary-note"><strong>边界说明:</strong>三个原型不代表共享账号、数据库或会话。Brain 页面仅供开发与验收理解内部流水线,不是第三个销售管理后台。</aside>
</main>
</body>
</html>
+147
View File
@@ -0,0 +1,147 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="YoVision Sense 面向网管与非技术设备管理员的 MVP 交互原型">
<title>YoVision Sense · 摄像头管理</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="../assets/styles.css">
<link rel="stylesheet" href="../assets/go-admin-ui.css">
<link rel="stylesheet" href="./sense.css">
</head>
<body class="go-admin-prototype sense-go-admin">
<svg class="sr-only" aria-hidden="true">
<symbol id="icon-menu" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></symbol>
<symbol id="icon-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></symbol>
<symbol id="icon-camera" viewBox="0 0 24 24"><path d="M15 10l4.5-2.5v9L15 14z"/><rect x="3" y="6" width="12" height="12" rx="2"/></symbol>
<symbol id="icon-monitor" viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="14" rx="2"/><path d="M8 22h8m-4-4v4"/></symbol>
<symbol id="icon-zone" viewBox="0 0 24 24"><path d="M5 4l14 2 2 12-11 3-7-8z"/><circle cx="5" cy="4" r="1"/><circle cx="19" cy="6" r="1"/><circle cx="21" cy="18" r="1"/><circle cx="10" cy="21" r="1"/><circle cx="3" cy="13" r="1"/></symbol>
<symbol id="icon-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="icon-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></symbol>
<symbol id="icon-refresh" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18 9a7 7 0 0 0-12-2L4 11m16 2-2 4a7 7 0 0 1-12-2"/></symbol>
<symbol id="icon-fullscreen" viewBox="0 0 24 24"><path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5"/></symbol>
<symbol id="icon-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></symbol>
<symbol id="icon-arrow" viewBox="0 0 24 24"><path d="M9 18l6-6-6-6"/></symbol>
</svg>
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="app-shell go-admin-shell" data-go-admin-shell>
<aside class="sidebar sidebar-container" data-sidebar aria-label="Sense 主导航">
<a class="brand sidebar-logo" href="../index.html"><span class="brand-mark">S</span><span class="brand-copy"><span class="brand-name">YoVision Sense</span><span class="brand-subtitle">摄像头与警戒区域管理</span></span></a>
<nav class="side-nav el-menu" aria-label="摄像头管理菜单">
<div class="menu-group"><svg aria-hidden="true"><use href="#icon-camera"></use></svg><span>日常管理</span><svg class="menu-arrow" aria-hidden="true"><use href="#icon-arrow"></use></svg></div>
<button class="active el-menu-item" data-view-target="overview" data-title="设备概况"><svg aria-hidden="true"><use href="#icon-grid"></use></svg><span>设备概况</span></button>
<button class="el-menu-item" data-view-target="devices" data-title="摄像头管理"><svg aria-hidden="true"><use href="#icon-camera"></use></svg><span>摄像头管理</span></button>
<button class="el-menu-item" data-view-target="live" data-title="实时画面"><svg aria-hidden="true"><use href="#icon-monitor"></use></svg><span>实时画面</span></button>
<button class="el-menu-item" data-view-target="zones" data-title="警戒区域"><svg aria-hidden="true"><use href="#icon-zone"></use></svg><span>警戒区域</span></button>
</nav>
<div class="side-note"><strong>日常使用提示</strong><span>先处理概况页中的异常项;技术参数只在“高级信息”中展示。</span></div>
</aside>
<div class="main-column main-container">
<div class="fixed-header">
<header class="topbar navbar">
<div class="topbar-left">
<button class="navbar-action mobile-menu" type="button" data-mobile-menu aria-label="打开主导航" aria-expanded="false"><svg aria-hidden="true"><use href="#icon-menu"></use></svg></button>
<button class="navbar-action desktop-collapse" type="button" data-collapse-sidebar aria-label="折叠主导航" aria-expanded="true"><svg aria-hidden="true"><use href="#icon-menu"></use></svg></button>
<nav class="breadcrumb" aria-label="面包屑"><a href="../index.html">YoVision</a><span>/</span><span>Sense</span><span>/</span><strong data-page-name>设备概况</strong></nav>
</div>
<div class="topbar-actions right-menu">
<button class="navbar-action" type="button" data-toast="可按摄像头名称或位置搜索" aria-label="全局搜索"><svg aria-hidden="true"><use href="#icon-search"></use></svg></button>
<button class="navbar-action desktop-only" type="button" data-toast="原型不切换浏览器全屏" aria-label="全屏"><svg aria-hidden="true"><use href="#icon-fullscreen"></use></svg></button>
<span class="prototype-pill">交互原型 · 非生产数据</span>
<button class="avatar-button" type="button" data-toast="当前身份:设备管理员" aria-label="打开用户菜单"><span class="avatar"><svg aria-hidden="true"><use href="#icon-user"></use></svg></span><span class="desktop-only">设备管理员</span></button>
</div>
</header>
<nav class="tags-view-container" aria-label="已访问页面">
<button class="tags-view-item active" data-view-target="overview" data-title="设备概况" aria-current="page">设备概况</button>
<button class="tags-view-item" data-view-target="devices" data-title="摄像头管理">摄像头管理 <span aria-hidden="true">×</span></button>
<button class="tags-view-item" data-view-target="live" data-title="实时画面">实时画面 <span aria-hidden="true">×</span></button>
<button class="tags-view-item" data-view-target="zones" data-title="警戒区域">警戒区域 <span aria-hidden="true">×</span></button>
</nav>
</div>
<main id="main-content" class="content app-main" tabindex="-1">
<section class="view active" data-view="overview">
<div class="page-header compact-header"><div><h1>设备概况</h1><p>先看需要处理的事项,其余摄像头和服务均正常运行。</p></div><div class="page-actions"><button class="btn el-button el-button-primary" data-open-modal="add-camera"><svg aria-hidden="true"><use href="#icon-plus"></use></svg>添加摄像头</button></div></div>
<div class="metric-grid">
<article class="metric el-card"><div class="metric-label">摄像头</div><div class="metric-value">6</div><div class="metric-foot"><strong>5 台正常</strong><span>· 1 台待处理</span></div></article>
<article class="metric el-card"><div class="metric-label">实时画面</div><div class="metric-value">5</div><div class="metric-foot"><strong>全部可查看</strong></div></article>
<article class="metric el-card"><div class="metric-label">已启用警戒区域</div><div class="metric-value">7</div><div class="metric-foot"><span>另有 1 个未发布草稿</span></div></article>
<article class="metric el-card"><div class="metric-label">需要处理</div><div class="metric-value">1</div><div class="metric-foot"><span class="status status-danger el-tag">账号验证失败</span></div></article>
</div>
<div class="grid grid-2">
<article class="card el-card attention-card"><header class="card-header"><div><h2>需要你处理</h2><p class="card-subtitle">每个问题都提供原因和下一步操作</p></div><span class="status status-danger el-tag">1 项</span></header><div class="card-body">
<div class="recovery-item"><div><strong>宿舍东门 02 暂时无法连接</strong><p>原因:摄像头拒绝了当前账号。影响:该位置暂时没有实时画面。</p></div><button class="btn el-button el-button-primary" data-open-modal="recover-camera">重新输入账号密码</button></div>
</div></article>
<article class="card el-card"><header class="card-header"><div><h2>系统运行情况</h2><p class="card-subtitle">日常使用只需关注是否正常</p></div><span class="status status-success el-tag">正常</span></header><div class="card-body stack">
<div class="description-row"><span>摄像头服务</span><strong>运行正常</strong><span class="status status-success el-tag">正常</span></div>
<div class="description-row"><span>配置保存</span><strong>已同步,12 秒前检查</strong><span class="status status-success el-tag">正常</span></div>
<details class="advanced-panel"><summary>查看高级信息</summary><div class="advanced-content"><div><span>媒体服务</span><strong>MediaMTX · 5/5 paths converged</strong></div><div><span>智能分析连接</span><strong>Brain adapter · 未配置(不影响独立运行)</strong></div><div><span>预警平台连接</span><strong>Bell connector · 未安装(不影响独立运行)</strong></div></div></details>
</div></article>
</div>
</section>
<section class="view" data-view="devices" hidden>
<div class="page-header compact-header"><div><h1>摄像头管理</h1><p>添加、检查和维护摄像头;系统会自动选择推荐的画面清晰度。</p></div><div class="page-actions"><button class="btn el-button el-button-primary" data-open-modal="add-camera"><svg aria-hidden="true"><use href="#icon-plus"></use></svg>添加摄像头</button></div></div>
<article class="card el-card box-card"><div class="card-body">
<form class="search-form el-form el-form-inline" data-go-admin-query data-query-input="#device-filter">
<div class="form-item"><label for="device-filter">摄像头</label><input id="device-filter" class="input el-input" data-filter-input="#device-table" placeholder="输入名称、位置或状态"></div>
<div class="form-item"><label for="device-status">运行状态</label><select id="device-status" class="input el-select"><option>全部状态</option><option>正常</option><option>需要处理</option><option>等待设置</option></select></div>
<div class="form-actions"><button class="btn el-button el-button-primary" type="submit"><svg aria-hidden="true"><use href="#icon-search"></use></svg>搜索</button><button class="btn el-button" type="reset"><svg aria-hidden="true"><use href="#icon-refresh"></use></svg>重置</button></div>
</form>
<div class="operation-bar"><div class="operation-actions"><button class="btn el-button" data-open-modal="add-camera">自动发现摄像头</button></div><span class="permission-hint">推荐先使用自动发现;找不到时再手工添加</span></div>
<div class="table-wrap"><table id="device-table" class="el-table"><thead><tr><th>摄像头</th><th>位置</th><th>运行状态</th><th>画面清晰度</th><th>操作</th></tr></thead><tbody>
<tr><td><span class="table-title">北围墙 01</span><span class="table-subtitle">已稳定运行 4 天</span></td><td>北围墙</td><td><span class="status status-success el-tag">正常</span></td><td>高清(推荐)</td><td><button class="btn btn-quiet el-button-link" data-view-target="live">查看画面</button></td></tr>
<tr><td><span class="table-title">宿舍东门 02</span><span class="table-subtitle">账号验证失败</span></td><td>宿舍东门</td><td><span class="status status-danger el-tag">需要处理</span></td><td>标清</td><td><button class="btn btn-quiet el-button-link" data-open-modal="recover-camera">重新输入账号密码</button></td></tr>
<tr><td><span class="table-title">食堂后门 01</span><span class="table-subtitle">已发现,尚未完成设置</span></td><td>食堂后门</td><td><span class="status status-warning el-tag">等待设置</span></td><td>系统将自动推荐</td><td><button class="btn btn-quiet el-button-link" data-open-modal="add-camera">继续设置</button></td></tr>
</tbody></table></div>
<details class="advanced-panel table-advanced"><summary>网管高级信息</summary><div class="advanced-content advanced-grid"><div><span>北围墙 01</span><strong>192.168.10.21 · ONVIF · MainStream 1080p</strong></div><div><span>宿舍东门 02</span><strong>192.168.10.32 · ONVIF · SubStream 720p</strong></div><div><span>食堂后门 01</span><strong>192.168.10.45 · 手工添加 · Profile 待探测</strong></div></div></details>
<nav class="pagination-container el-pagination" aria-label="摄像头列表分页"><span class="pagination-total">共 3 条</span><button type="button" disabled aria-label="上一页">‹</button><button type="button" class="active" aria-current="page">1</button><button type="button" disabled aria-label="下一页">›</button><select class="pagination-size" aria-label="每页条数"><option>10 条/页</option><option>20 条/页</option></select></nav>
</div></article>
</section>
<section class="view" data-view="live" hidden>
<div class="page-header compact-header"><div><h1>实时画面</h1><p>选择摄像头即可查看;连接中断时画面会保留并给出恢复操作。</p></div><div class="page-actions"><select class="input el-select" aria-label="选择摄像头"><option>北围墙 01</option><option>宿舍东门 02(需要处理)</option></select><button class="btn el-button" data-toast="正在重新连接,当前画面会继续保留"><svg aria-hidden="true"><use href="#icon-refresh"></use></svg>重新连接</button></div></div>
<div class="grid grid-2"><article class="card el-card"><header class="card-header"><h2>北围墙 01</h2><span class="status status-success el-tag">画面正常</span></header><div class="card-body"><div class="video-stage sense-video-stage" role="img" aria-label="北围墙摄像头的模拟实时画面,画面中显示危险区域"><div class="camera-caption">北围墙 01 · 高清</div><div class="zone-shape"></div><span class="zone-label">危险区域 A</span><span class="video-time">2026-08-12 10:18:24</span></div></div></article><article class="card el-card"><header class="card-header"><h2>画面状态</h2></header><div class="card-body description-list"><div class="description-row"><span>当前质量</span><strong>高清 · 画面流畅</strong></div><div class="description-row"><span>连接状态</span><strong>稳定运行 4 小时 18 分</strong></div><details class="advanced-panel"><summary>查看高级信息</summary><div class="advanced-content"><div><span>协议与服务</span><strong>WebRTC / MediaMTX</strong></div><div><span>源画面</span><strong>MainStream · 1920×1080</strong></div><div><span>实时指标</span><strong>1.8 Mbps · 延迟 340 ms</strong></div></div></details></div></article></div>
</section>
<section class="view" data-view="zones" hidden>
<div class="page-header compact-header"><div><h1>警戒区域</h1><p>在画面上标出需要重点关注的区域,保存后再发布生效。</p></div><div class="page-actions"><button class="btn el-button" data-toast="警戒方向已反转,尚未保存">反转警戒方向</button><button class="btn el-button el-button-primary" data-toast="草稿已保存,发布前不会影响当前规则">保存草稿</button></div></div>
<div class="grid grid-2"><article class="card el-card"><header class="card-header"><div><h2>在画面上绘制</h2><p class="card-subtitle">拖动控制点调整范围</p></div></header><div class="card-body"><div class="video-stage sense-zone-canvas" role="img" aria-label="区域编辑画布,包含五边形危险区域"><div class="camera-caption">北围墙 01 · 高清画面</div><div class="zone-shape"></div><span class="zone-label">危险区域 A · 未发布</span></div></div></article><article class="card el-card"><header class="card-header"><h2>区域设置</h2><span class="status status-warning el-tag">未发布</span></header><div class="card-body"><form class="el-form vertical-form"><div class="form-item"><label for="zone-name">区域名称</label><input id="zone-name" class="input el-input" value="危险区域 A"></div><div class="form-item"><label for="zone-rule">发现以下情况时提醒</label><select id="zone-rule" class="input el-select"><option>有人进入危险区域</option><option>有人反向越过警戒线</option><option>区域内人员聚集</option></select></div><div class="form-item"><label for="zone-camera">使用摄像头</label><input id="zone-camera" class="input el-input" value="北围墙 01 · 高清" readonly></div><div class="boundary-note">如果更换画面清晰度,系统会提示重新校准,避免警戒范围偏移。</div><details class="advanced-panel"><summary>网管高级信息</summary><div class="advanced-content"><div><span>绑定画面</span><strong>MainStream · 1920×1080</strong></div><div><span>保存方式</span><strong>归一化坐标 · 草稿 v8</strong></div></div></details><div class="form-footer"><button type="button" class="btn el-button el-button-primary" data-toast="警戒区域已发布并生效;上一版本仍可追溯">发布并生效</button></div></form></div></article></div>
</section>
</main>
</div>
</div>
<div class="modal-backdrop el-overlay" id="add-camera" aria-hidden="true">
<section class="modal el-dialog wizard-dialog" role="dialog" aria-modal="true" aria-labelledby="add-camera-title">
<header class="modal-header el-dialog-header"><div><h2 id="add-camera-title">添加摄像头</h2><p class="dialog-subtitle" data-wizard-step-label>第 1 步,共 6 步</p></div><button class="btn btn-icon btn-quiet dialog-close" data-close-modal aria-label="关闭添加摄像头窗口">×</button></header>
<div class="modal-body el-dialog-body">
<ol class="wizard-progress" aria-label="添加摄像头进度"><li class="active">查找方式</li><li>选择设备</li><li>账号</li><li>测试</li><li>预览</li><li>完成</li></ol>
<div class="wizard-panel active" data-wizard-panel="0"><h3>怎样查找摄像头?</h3><p>推荐自动查找同一网络中的摄像头;找不到时再手工填写地址。</p><label class="choice-card selected"><input type="radio" name="discover-method" value="auto" checked><span><strong>自动查找(推荐)</strong><small>无需知道网络地址,约需 10 秒</small></span></label><label class="choice-card"><input type="radio" name="discover-method" value="manual"><span><strong>手工填写地址</strong><small>适合已知道摄像头地址的网管</small></span></label></div>
<div class="wizard-panel" data-wizard-panel="1" hidden><div data-auto-choice><h3>选择要添加的摄像头</h3><p>已找到 2 台尚未添加的设备。</p><label class="choice-card selected"><input type="radio" name="camera-choice" checked><span><strong>教学楼西门 · 192.168.10.88</strong><small>海康摄像头 · 信号正常</small></span></label><label class="choice-card"><input type="radio" name="camera-choice"><span><strong>体育馆北门 · 192.168.10.91</strong><small>大华摄像头 · 信号正常</small></span></label></div><div data-manual-choice hidden><h3>填写摄像头地址</h3><p>请输入网管提供的局域网地址,系统会自动检查可用的接入方式。</p><div class="form-item"><label for="manual-address">摄像头地址</label><input id="manual-address" class="input el-input" inputmode="url" placeholder="例如 192.168.10.88"><span class="help">无需填写协议、端口或画面配置,系统会自动探测。</span></div></div></div>
<div class="wizard-panel" data-wizard-panel="2" hidden><h3>输入摄像头账号</h3><p>账号只用于连接该摄像头,保存后不会显示密码。</p><form class="el-form vertical-form"><div class="form-item"><label for="wizard-name">摄像头名称</label><input id="wizard-name" class="input el-input" value="教学楼西门 01"></div><div class="form-item"><label for="wizard-user">用户名</label><input id="wizard-user" class="input el-input" value="operator" autocomplete="off"></div><div class="form-item"><label for="wizard-password">密码</label><input id="wizard-password" class="input el-input" type="password" autocomplete="new-password"><span class="help">原型不会保存输入内容。</span></div></form></div>
<div class="wizard-panel" data-wizard-panel="3" hidden><h3>测试连接</h3><p>系统会检查账号、网络和画面,并自动选择推荐清晰度。</p><div class="test-status" data-connection-status role="status"><strong>尚未测试</strong><span>点击“测试连接”继续。</span></div><div class="inline-actions"><button class="btn el-button el-button-primary" type="button" data-test-connection>测试连接</button><button class="btn el-button" type="button" data-simulate-failure>演示失败与恢复</button></div></div>
<div class="wizard-panel" data-wizard-panel="4" hidden><h3>确认画面</h3><p>画面清楚且位置正确即可继续。</p><div class="video-stage wizard-preview" role="img" aria-label="教学楼西门摄像头预览画面"><div class="camera-caption">教学楼西门 01 · 高清(推荐)</div><span class="video-time">实时预览</span></div><details class="advanced-panel"><summary>网管高级信息</summary><div class="advanced-content"><div><span>接入方式</span><strong>ONVIF</strong></div><div><span>画面配置</span><strong>MainStream · 1920×1080</strong></div></div></details></div>
<div class="wizard-panel" data-wizard-panel="5" hidden><div class="completion-state"><span class="completion-mark" aria-hidden="true">✓</span><h3>摄像头已准备好</h3><p>教学楼西门 01 将以高清画面运行。完成后可立即查看实时画面或配置警戒区域。</p></div></div>
</div>
<footer class="modal-footer el-dialog-footer"><button class="btn el-button" type="button" data-wizard-back disabled>上一步</button><button class="btn el-button el-button-primary" type="button" data-wizard-next>下一步</button></footer>
</section>
</div>
<div class="modal-backdrop el-overlay" id="recover-camera" aria-hidden="true">
<section class="modal el-dialog" role="dialog" aria-modal="true" aria-labelledby="recover-title">
<header class="modal-header el-dialog-header"><h2 id="recover-title">恢复宿舍东门 02</h2><button class="btn btn-icon btn-quiet dialog-close" data-close-modal aria-label="关闭恢复窗口">×</button></header>
<div class="modal-body el-dialog-body"><div class="recovery-summary"><strong>摄像头拒绝了当前账号</strong><p>该位置暂时没有实时画面。请重新输入摄像头账号密码,然后测试连接。</p></div><form class="el-form vertical-form"><div class="form-item"><label for="recover-user">用户名</label><input id="recover-user" class="input el-input" value="operator"></div><div class="form-item"><label for="recover-password">新密码</label><input id="recover-password" class="input el-input" type="password" autocomplete="new-password" placeholder="输入摄像头当前密码"><span class="field-error" data-recovery-error role="alert" hidden>密码不能为空,请输入后再测试。</span></div></form><div class="test-status" data-recovery-status role="status"><strong>等待测试</strong><span>保存前必须确认能够连接。</span></div><details class="advanced-panel"><summary>网管高级信息</summary><div class="advanced-content"><div><span>设备地址</span><strong>192.168.10.32</strong></div><div><span>错误来源</span><strong>ONVIF authentication rejected</strong></div></div></details></div>
<footer class="modal-footer el-dialog-footer"><button class="btn el-button" data-close-modal>取消</button><button class="btn el-button" type="button" data-recovery-test>测试连接</button><button class="btn el-button el-button-primary" type="button" data-recovery-save disabled>保存并恢复</button></footer>
</section>
</div>
<div class="toast-region" data-toast-region aria-live="polite"></div>
<script src="../assets/app.js"></script>
<script src="../assets/go-admin-shell.js"></script>
<script src="./sense.js"></script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
.sense-go-admin .attention-card { border-top: 3px solid var(--danger); }
.sense-go-admin .recovery-item { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.sense-go-admin .recovery-item p,
.sense-go-admin .wizard-panel > p,
.sense-go-admin .recovery-summary p { margin: 6px 0 0; color: var(--text-muted); line-height: 1.6; }
.advanced-panel { margin-top: 14px; border: 1px solid var(--border); border-radius: 6px; background: #f8fafc; }
.advanced-panel summary { padding: 11px 14px; cursor: pointer; color: #475569; font-weight: 600; }
.advanced-panel summary:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.advanced-content { display: grid; gap: 10px; padding: 0 14px 14px; }
.advanced-content > div { display: flex; justify-content: space-between; gap: 16px; color: var(--text-muted); }
.advanced-content strong { color: var(--text); text-align: right; overflow-wrap: anywhere; }
.table-advanced { margin-bottom: 12px; }
.advanced-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.advanced-grid > div { display: grid; }
.advanced-grid strong { text-align: left; }
.wizard-dialog { width: min(720px, calc(100vw - 32px)); }
.dialog-subtitle { margin: 4px 0 0; color: var(--text-muted); font-size: 13px; }
.wizard-progress { display: grid; grid-template-columns: repeat(6, 1fr); gap: 4px; padding: 0; margin: 0 0 24px; list-style: none; counter-reset: wizard; }
.wizard-progress li { position: relative; padding-top: 22px; color: var(--text-muted); font-size: 12px; text-align: center; }
.wizard-progress li::before { counter-increment: wizard; content: counter(wizard); position: absolute; top: 0; left: 50%; width: 18px; height: 18px; transform: translateX(-50%); border: 1px solid var(--border); border-radius: 50%; background: #fff; line-height: 18px; }
.wizard-progress li::after { content: ""; position: absolute; top: 9px; left: calc(50% + 11px); width: calc(100% - 22px); height: 1px; background: var(--border); }
.wizard-progress li:last-child::after { display: none; }
.wizard-progress li.active { color: var(--primary); font-weight: 700; }
.wizard-progress li.active::before { border-color: var(--primary); background: var(--primary); color: #fff; }
.wizard-panel h3 { margin: 0 0 4px; font-size: 18px; }
.choice-card { display: flex; align-items: flex-start; gap: 12px; min-height: 66px; margin-top: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; }
.choice-card:has(input:checked) { border-color: var(--primary); background: #eff6ff; box-shadow: 0 0 0 1px var(--primary); }
.choice-card input { margin-top: 4px; }
.choice-card span { display: grid; gap: 4px; }
.choice-card small { color: var(--text-muted); }
.test-status { display: grid; gap: 4px; margin: 18px 0; padding: 14px; border-left: 4px solid #94a3b8; border-radius: 4px; background: #f8fafc; }
.test-status.testing { border-color: var(--primary); background: #eff6ff; }
.test-status.success { border-color: var(--success); background: #f0fdf4; }
.test-status.error { border-color: var(--danger); background: #fef2f2; }
.test-status span { color: var(--text-muted); }
.inline-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.wizard-preview { min-height: 260px; background: linear-gradient(140deg, #27364a, #526984); }
.completion-state { padding: 28px 12px; text-align: center; }
.completion-mark { display: inline-grid; width: 54px; height: 54px; place-items: center; border-radius: 50%; background: #16a34a; color: #fff; font-size: 30px; }
.completion-state h3 { margin-top: 16px; }
.recovery-summary { padding: 14px; border-left: 4px solid var(--danger); border-radius: 4px; background: #fef2f2; }
.field-error { margin-top: 6px; color: #b91c1c; font-size: 13px; }
@media (max-width: 767px) {
.sense-go-admin .recovery-item { align-items: stretch; flex-direction: column; }
.advanced-content > div { display: grid; }
.advanced-content strong { text-align: left; }
.advanced-grid { grid-template-columns: 1fr; }
.wizard-progress li { font-size: 0; }
.wizard-progress li::before { font-size: 12px; }
.wizard-dialog .modal-body { padding-inline: 16px; }
}
+122
View File
@@ -0,0 +1,122 @@
(function () {
"use strict";
const qs = (selector, root = document) => root.querySelector(selector);
const qsa = (selector, root = document) => [...root.querySelectorAll(selector)];
const toastRegion = qs("[data-toast-region]");
const announce = (message) => {
if (!toastRegion) return;
const item = document.createElement("div");
item.className = "toast";
item.setAttribute("role", "status");
item.textContent = message;
toastRegion.appendChild(item);
window.setTimeout(() => item.remove(), 3600);
};
qsa(".choice-card input").forEach((input) => {
input.addEventListener("change", () => {
qsa(`input[name="${input.name}"]`).forEach((peer) => peer.closest(".choice-card")?.classList.toggle("selected", peer.checked));
if (input.name === "discover-method") {
const manual = input.value === "manual";
qs("[data-auto-choice]").hidden = manual;
qs("[data-manual-choice]").hidden = !manual;
}
});
});
const wizard = qs("#add-camera");
if (wizard) {
let step = 0;
let connectionPassed = false;
const panels = qsa("[data-wizard-panel]", wizard);
const progress = qsa(".wizard-progress li", wizard);
const back = qs("[data-wizard-back]", wizard);
const next = qs("[data-wizard-next]", wizard);
const stepLabel = qs("[data-wizard-step-label]", wizard);
const connectionStatus = qs("[data-connection-status]", wizard);
const render = () => {
panels.forEach((panel, index) => {
panel.hidden = index !== step;
panel.classList.toggle("active", index === step);
});
progress.forEach((item, index) => item.classList.toggle("active", index <= step));
stepLabel.textContent = `第 ${step + 1} 步,共 ${panels.length} 步`;
back.disabled = step === 0;
next.textContent = step === panels.length - 1 ? "完成" : "下一步";
next.disabled = step === 3 && !connectionPassed;
qs("input, button, h3[tabindex]", panels[step])?.focus({ preventScroll: true });
};
qsa("[data-open-modal='add-camera']").forEach((trigger) => trigger.addEventListener("click", () => {
step = trigger.textContent.includes("继续") ? 2 : 0;
connectionPassed = false;
connectionStatus.className = "test-status";
connectionStatus.innerHTML = "<strong>尚未测试</strong><span>点击“测试连接”继续。</span>";
render();
}));
back.addEventListener("click", () => { if (step > 0) { step -= 1; render(); } });
next.addEventListener("click", () => {
if (step < panels.length - 1) { step += 1; render(); return; }
qs("[data-close-modal]", wizard)?.click();
announce("教学楼西门 01 已添加,可立即查看实时画面");
});
qs("[data-test-connection]", wizard)?.addEventListener("click", (event) => {
const button = event.currentTarget;
button.disabled = true;
button.textContent = "正在测试…";
connectionStatus.className = "test-status testing";
connectionStatus.innerHTML = "<strong>正在检查账号和画面</strong><span>通常几秒内完成。</span>";
window.setTimeout(() => {
connectionPassed = true;
connectionStatus.className = "test-status success";
connectionStatus.innerHTML = "<strong>连接成功</strong><span>画面正常,已自动选择高清(推荐)。</span>";
button.disabled = false;
button.textContent = "重新测试";
render();
}, 520);
});
qs("[data-simulate-failure]", wizard)?.addEventListener("click", () => {
connectionPassed = false;
connectionStatus.className = "test-status error";
connectionStatus.innerHTML = "<strong>账号验证失败</strong><span>请返回上一步检查用户名和密码,然后重新测试。</span>";
next.disabled = true;
announce("连接失败:请检查账号密码后重试");
});
}
const recovery = qs("#recover-camera");
if (recovery) {
const password = qs("#recover-password", recovery);
const error = qs("[data-recovery-error]", recovery);
const status = qs("[data-recovery-status]", recovery);
const save = qs("[data-recovery-save]", recovery);
qs("[data-recovery-test]", recovery)?.addEventListener("click", (event) => {
const testButton = event.currentTarget;
if (!password.value.trim()) {
error.hidden = false;
password.setAttribute("aria-invalid", "true");
password.focus();
return;
}
error.hidden = true;
password.removeAttribute("aria-invalid");
testButton.disabled = true;
testButton.textContent = "正在测试…";
status.className = "test-status testing";
status.innerHTML = "<strong>正在重新连接</strong><span>正在检查账号和实时画面。</span>";
window.setTimeout(() => {
status.className = "test-status success";
status.innerHTML = "<strong>连接成功</strong><span>实时画面已恢复,可以安全保存。</span>";
testButton.disabled = false;
testButton.textContent = "重新测试";
save.disabled = false;
}, 520);
});
save.addEventListener("click", () => {
qs("[data-close-modal]", recovery)?.click();
announce("宿舍东门 02 的账号已更新,实时画面恢复正常");
});
}
})();