feat: alarm notification — topbar bell badge + title flash with unacknowledged count

This commit is contained in:
tian 2026-07-17 17:32:19 +08:00
parent 13d22ce848
commit 3d7313585c
4 changed files with 58 additions and 13 deletions

View File

@ -6,7 +6,7 @@
|---|------|------|------|------|
| 1 | 告警中心增强 | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
| 2 | 仪表盘重做(实时告警流+统计卡片) | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
| 3 | 一键配置向导(场景化部署 3 步流程) | 🔄 进行中 | 2026-07-17 | - |
| 3 | 一键配置向导(场景化部署 3 步流程) | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
| 4 | 基础登录鉴权 | ⬜ 待开始 | - | - |
| 5 | 监控页 HLS.js 本地化 | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
@ -14,7 +14,7 @@
| # | 事项 | 状态 | 开始 | 完成 |
|---|------|------|------|------|
| 6 | 告警浏览器通知SSE + 标题闪烁) | ⬜ 待开始 | - | - |
| 6 | 告警浏览器通知SSE / 标题闪烁) | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
| 7 | 设备列表服务健康指示 | ⬜ 待开始 | - | - |
| 8 | 操作结果提示优化toast 替代 URL 消息) | ⬜ 待开始 | - | - |
| 9 | 术语统一 | ⬜ 待开始 | - | - |
@ -32,19 +32,22 @@
## 进行中
### P0-3 一键配置向导
| # | 子任务 | 状态 |
|---|--------|------|
| 3.1 | 路由 + 3 个 Handler页面、创建设备、下发配置 | ✅ |
| 3.2 | 3 步模板:选设备→配检测→确认下发 | ✅ |
| 3.3 | 内联添加摄像头RTSP URL | ✅ |
| 3.4 | 菜单项(标准模式可见) | ✅ |
(无)
## 已完成事项
### 2026-07-17
- **P0-3 一键配置向导**
- 3 步引导:选设备 → 配检测(从设备读取运行通道,下拉选已有视频源或手动添加) → 一键下发
- 标准模式可见,复用的 AutoConfigService 链路
- **P1-6 告警浏览器通知**
- 顶栏铃铛图标显示未处理告警数(红点+数字徽章)
- 页面标题闪烁 `(N) SafeSight`
- 每 15 秒轮询 `/api/alarms/unacknowledged-count`
- `render` 统一注入计数,所有页面生效
- **P0-2 仪表盘重做**
- 统计卡片:今日告警/未处理告警/在线设备/检测路数
- SSE 实时告警流EventSource 连接 /api/alarms/stream新告警实时推入
@ -67,4 +70,4 @@
---
**已完成:2/1513.3%** | **进行中1P0-3**
**已完成:4/1526.7%**

View File

@ -802,6 +802,7 @@ func (u *UI) Routes() (chi.Router, error) {
r.Post("/alarms/{id}/resolve", u.actionResolveAlarm)
r.Post("/alarms/{id}/severity", u.actionUpdateAlarmSeverity)
r.Get("/api/alarms/stream", u.apiAlarmStream)
r.Get("/api/alarms/unacknowledged-count", u.apiAlarmUnacknowledgedCount)
r.Get("/face-gallery", u.pageFaceGallery)
r.Get("/face-photo/*", u.serveFacePhoto)
r.Post("/face-gallery/import", u.actionFaceGalleryImport)
@ -826,6 +827,9 @@ func (u *UI) Routes() (chi.Router, error) {
func (u *UI) render(w http.ResponseWriter, r *http.Request, content string, data PageData) {
data.Version = version
data.Year = time.Now().Year()
if u.alarmCollector != nil {
data.UnacknowledgedAlarmCount = u.alarmCollector.GetUnacknowledgedCount()
}
var buf bytes.Buffer
if err := u.tpl.ExecuteTemplate(&buf, content, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@ -2319,6 +2323,15 @@ func (u *UI) apiAlarmStream(w http.ResponseWriter, r *http.Request) {
}
}
func (u *UI) apiAlarmUnacknowledgedCount(w http.ResponseWriter, r *http.Request) {
count := 0
if u.alarmCollector != nil {
count = u.alarmCollector.GetUnacknowledgedCount()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int{"count": count})
}
func (u *UI) pageResources(w http.ResponseWriter, r *http.Request) {
u.ensureDevicesLoaded()
data := PageData{Title: "资源管理", Devices: u.registry.GetDevices()}

View File

@ -149,6 +149,7 @@ code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.topbar-icon-btn:hover{background:var(--button-soft-hover);border-color:var(--primary);color:var(--text)}
.topbar-icon-btn .ui-icon{width:16px;height:16px}
.topbar-dot{position:absolute;right:6px;top:6px;width:6px;height:6px;border-radius:999px;background:var(--red)}
.topbar-badge{position:absolute;right:-4px;top:-4px;min-width:16px;height:16px;padding:0 4px;border-radius:8px;background:var(--red);color:#fff;font-size:10px;font-weight:600;line-height:16px;text-align:center}
.theme-menu{position:relative}
.theme-menu-panel{position:fixed;right:28px;top:54px;min-width:132px;padding:6px;border:1px solid var(--border-strong);border-radius:var(--radius);background:var(--surface);box-shadow:var(--shadow);z-index:9999}
.theme-menu-panel button{display:flex;width:100%;justify-content:flex-start;min-height:28px;border-color:transparent;background:transparent;color:var(--text)}

View File

@ -70,9 +70,10 @@
<button type="button" data-theme-option="graphite-gold">石墨金色</button>
</div>
</div>
<a class="topbar-icon-btn" href="/ui/diagnostics" aria-label="日志审计" title="日志审计">
<a class="topbar-icon-btn" href="/ui/alarms" aria-label="告警中心" title="告警中心" id="topbar-alarm-btn">
{{icon "bell"}}
<span class="topbar-dot" aria-hidden="true"></span>
<span class="topbar-dot" id="alarm-dot" {{if eq .UnacknowledgedAlarmCount 0}}hidden{{end}} aria-hidden="true"></span>
<span class="topbar-badge" id="alarm-badge" {{if eq .UnacknowledgedAlarmCount 0}}hidden{{end}}>{{.UnacknowledgedAlarmCount}}</span>
</a>
<a class="topbar-icon-btn" href="/ui/system" aria-label="系统" title="系统">
{{icon "system"}}
@ -361,6 +362,33 @@
applyMode(current === "expert" ? "standard" : "expert", true);
});
var originalTitle = document.title;
var alarmDot = document.getElementById("alarm-dot");
var alarmBadge = document.getElementById("alarm-badge");
function updateAlarmBadge(count) {
if (!alarmDot || !alarmBadge) return;
if (count > 0) {
alarmDot.hidden = false;
alarmBadge.hidden = false;
alarmBadge.textContent = count > 99 ? "99+" : count;
document.title = "(" + count + ") " + originalTitle;
} else {
alarmDot.hidden = true;
alarmBadge.hidden = true;
document.title = originalTitle;
}
}
function pollAlarmCount() {
fetch("/api/alarms/unacknowledged-count", {credentials:"same-origin"})
.then(function(r){return r.json()})
.then(function(data){updateAlarmBadge(data.count||0)})
.catch(function(){});
}
setInterval(pollAlarmCount, 15000);
})();
</script>
</body>