feat: CSV alarm export — supports filters, Excel-compatible with BOM

This commit is contained in:
tian 2026-07-17 18:16:10 +08:00
parent 3adc1e1a43
commit 232526de06
4 changed files with 96 additions and 2 deletions

View File

@ -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/1553.3%**
**已完成:9/1560%**

View File

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

View File

@ -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()}

View File

@ -26,6 +26,9 @@
<a href="/ui/alarms?from={{.WeekStart}}&to={{.WeekEnd}}" class="btn ghost" style="font-size:11px">本周</a>
<a href="/ui/alarms?from={{.MonthStart}}&to={{.MonthEnd}}" class="btn ghost" style="font-size:11px">本月</a>
<a href="/ui/alarms?from=2020-01-01&to={{.SelectedTo}}" class="btn ghost" style="font-size:11px">全部</a>
{{if .AlarmRecords}}
<a class="btn ghost js-export-csv" style="font-size:11px;margin-left:8px">导出 CSV</a>
{{end}}
</span>
<select name="per_page" onchange="this.form.submit()" style="font-size:11px;padding:2px 4px;width:90px;flex-shrink:0">
<option value="10" {{if eq .PerPage 10}}selected{{end}}>10条/页</option>
@ -149,4 +152,21 @@
});
})();
</script>
<script>
(function(){
var exportBtn = document.querySelector(".js-export-csv");
if (exportBtn) {
exportBtn.addEventListener("click", function(){
var params = new URLSearchParams(window.location.search);
var exportParams = new URLSearchParams();
if (params.get("from")) exportParams.set("from", params.get("from"));
if (params.get("to")) exportParams.set("to", params.get("to"));
if (params.get("device")) exportParams.set("device", params.get("device"));
if (params.get("rule_type")) exportParams.set("rule_type", params.get("rule_type"));
window.location.href = "/api/alarms/export?" + exportParams.toString();
});
}
})();
</script>
{{end}}