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}}