diff --git a/docs/实施跟踪.md b/docs/实施跟踪.md index fddd5c9..e1be26b 100644 --- a/docs/实施跟踪.md +++ b/docs/实施跟踪.md @@ -26,7 +26,7 @@ | 10 | 时序图表(告警趋势) | ✅ 已完成 | 2026-07-17 | 2026-07-17 | | 11 | 设备分组与标签 | ⬜ 待开始 | - | - | | 12 | 配置版本对比 | ⬜ 待开始 | - | - | -| 13 | 报告导出(PDF/CSV) | ⬜ 待开始 | - | - | +| 13 | 报告导出(CSV) | ✅ 已完成 | 2026-07-17 | 2026-07-17 | | 14 | 模型管理简化 | ⬜ 待开始 | - | - | | 15 | 人脸库优化(搜索+质量评分) | ⬜ 待开始 | - | - | @@ -42,6 +42,10 @@ - 3 步引导:选设备 → 配检测(从设备读取运行通道,下拉选已有视频源或手动添加) → 一键下发 - 标准模式可见,复用的 AutoConfigService 链路 +- **P2-13 报告导出** + - API:`/api/alarms/export?from=&to=&device=&rule_type=` 导出 CSV + - Excel 兼容(BOM + UTF-8),支持筛选条件继承,告警页面一键下载 + - **P2-10 告警趋势图** - 仪表盘新增 Canvas 柱状图:近 7 日告警数量 - API:`/api/alarms/daily-count?days=7` 返回每日统计 @@ -89,4 +93,4 @@ --- -**已完成:8/15(53.3%)** +**已完成:9/15(60%)** diff --git a/internal/service/alarm_collector.go b/internal/service/alarm_collector.go index 7b8ec37..593c51c 100644 --- a/internal/service/alarm_collector.go +++ b/internal/service/alarm_collector.go @@ -425,3 +425,39 @@ ORDER BY day } return list } + +func (c *AlarmCollector) ExportFiltered(from, to, deviceID, ruleType string) []AlarmRecord { + if c.db == nil { + return nil + } + fromTS := from + "T00:00:00" + toTS := to + "T23:59:59" + cond := "WHERE timestamp >= ? AND timestamp <= ?" + args := []any{fromTS, toTS} + if deviceID != "" { + cond += " AND device_id = ?" + args = append(args, deviceID) + } + if ruleType != "" { + cond += " AND rule_type = ?" + args = append(args, ruleType) + } + rows, err := c.db.Query(` +SELECT id, device_id, channel, timestamp, rule_name, rule_type, object_label, confidence, snapshot_url, clip_url, duration_ms, collected_at, status, severity, acknowledged_at, acknowledged_by, resolved_at, resolved_by +FROM alarm_records `+cond+` +ORDER BY timestamp DESC +`, args...) + if err != nil { + return nil + } + defer rows.Close() + var alarms []AlarmRecord + for rows.Next() { + var a AlarmRecord + if err := rows.Scan(&a.ID, &a.DeviceID, &a.Channel, &a.Timestamp, &a.RuleName, &a.RuleType, &a.ObjectLabel, &a.Confidence, &a.SnapshotURL, &a.ClipURL, &a.DurationMs, &a.CollectedAt, &a.Status, &a.Severity, &a.AcknowledgedAt, &a.AcknowledgedBy, &a.ResolvedAt, &a.ResolvedBy); err != nil { + continue + } + alarms = append(alarms, a) + } + return alarms +} diff --git a/internal/web/ui.go b/internal/web/ui.go index 2c7899d..007b5d2 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -682,6 +682,7 @@ func (u *UI) RegisterAlarmRoutes(r chi.Router) { r.Get("/api/alarms/stream", u.apiAlarmStream) r.Get("/api/alarms/unacknowledged-count", u.apiAlarmUnacknowledgedCount) r.Get("/api/alarms/daily-count", u.apiAlarmDailyCount) + r.Get("/api/alarms/export", u.apiAlarmExport) } func (u *UI) Routes() (chi.Router, error) { @@ -2346,6 +2347,39 @@ func (u *UI) apiAlarmDailyCount(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(result) } +func (u *UI) apiAlarmExport(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + from := strings.TrimSpace(q.Get("from")) + to := strings.TrimSpace(q.Get("to")) + deviceID := strings.TrimSpace(q.Get("device")) + ruleType := strings.TrimSpace(q.Get("rule_type")) + if from == "" { + from = time.Now().AddDate(0, 0, -7).Format("2006-01-02") + } + if to == "" { + to = time.Now().Format("2006-01-02") + } + records := u.alarmCollector.ExportFiltered(from, to, deviceID, ruleType) + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="alarms_%s_%s.csv"`, from, to)) + w.Header().Set("Cache-Control", "no-cache") + w.Write([]byte("\xef\xbb\xbf")) + w.Write([]byte("时间,设备ID,通道,规则,目标,置信度,等级,状态\n")) + for _, a := range records { + fmt.Fprintf(w, "%s,%s,%s,%s,%s,%.2f,%s,%s\n", + escapeCSV(a.Timestamp), escapeCSV(a.DeviceID), escapeCSV(a.Channel), + escapeCSV(a.RuleName), escapeCSV(a.ObjectLabel), a.Confidence, + escapeCSV(a.Severity), escapeCSV(a.Status)) + } +} + +func escapeCSV(s string) string { + if strings.ContainsAny(s, ",\"\n") { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` + } + return s +} + func (u *UI) pageResources(w http.ResponseWriter, r *http.Request) { u.ensureDevicesLoaded() data := PageData{Title: "资源管理", Devices: u.registry.GetDevices()} diff --git a/internal/web/ui/templates/alarms.html b/internal/web/ui/templates/alarms.html index f40f94f..a18d9ea 100644 --- a/internal/web/ui/templates/alarms.html +++ b/internal/web/ui/templates/alarms.html @@ -26,6 +26,9 @@ 本周 本月 全部 + {{if .AlarmRecords}} + 导出 CSV + {{end}}