diff --git a/internal/web/ui.go b/internal/web/ui.go index 54b4dde..9697622 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -3,6 +3,7 @@ package web import ( "bytes" "crypto/sha256" + "encoding/csv" "encoding/hex" "encoding/json" "fmt" @@ -748,6 +749,8 @@ func (u *UI) Routes() (chi.Router, error) { r.Post("/device-assignments/{id}/delete", u.actionDeviceAssignmentDelete) r.Get("/assets", u.pageAssets) r.Get("/assets/video-sources", u.pageAssetVideoSources) + r.Get("/assets/video-sources/template", u.actionVideoSourceCSVTemplate) + r.Post("/assets/video-sources/import", u.actionVideoSourceCSVImport) r.Post("/assets/video-sources", u.actionAssetVideoSourceSave) r.Post("/assets/video-sources/{name}/delete", u.actionAssetVideoSourceDelete) r.Get("/assets/templates", u.pageAssetTemplates) @@ -3522,6 +3525,105 @@ func (u *UI) actionAssetVideoSourceDelete(w http.ResponseWriter, r *http.Request http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已删除"), http.StatusFound) } +func (u *UI) actionVideoSourceCSVTemplate(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", "attachment; filename=video_sources_template.csv") + w.Write([]byte("\xef\xbb\xbf")) // BOM for Excel + w.Write([]byte("name,source_type,area,url,resolution,fps,description\n")) + w.Write([]byte("车间东门,rtsp,车间A,rtsp://192.168.1.100:554/stream1,1920x1080,25,\n")) + w.Write([]byte("车间西门,rtsp,车间A,rtsp://192.168.1.101:554/stream1,1920x1080,25,\n")) +} + +func (u *UI) actionVideoSourceCSVImport(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "not available", http.StatusServiceUnavailable) + return + } + if err := r.ParseMultipartForm(10 << 20); err != nil { + http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape("文件解析失败"), http.StatusFound) + return + } + file, _, err := r.FormFile("file") + if err != nil { + http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape("请选择文件"), http.StatusFound) + return + } + defer file.Close() + + reader := csv.NewReader(file) + reader.LazyQuotes = true + records, err := reader.ReadAll() + if err != nil { + http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape("CSV 解析失败: "+err.Error()), http.StatusFound) + return + } + if len(records) < 2 { + http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape("CSV 文件为空或无数据行"), http.StatusFound) + return + } + + imported := 0 + skipped := 0 + for i, row := range records { + if i == 0 { + continue // skip header + } + if len(row) < 4 { + skipped++ + continue + } + name := strings.TrimSpace(row[0]) + sourceType := strings.TrimSpace(row[1]) + area := "" + url := "" + resolution := "" + fps := "" + if len(row) > 2 { + area = strings.TrimSpace(row[2]) + } + if len(row) > 3 { + url = strings.TrimSpace(row[3]) + } + if len(row) > 4 { + resolution = strings.TrimSpace(row[4]) + } + if len(row) > 5 { + fps = strings.TrimSpace(row[5]) + } + if name == "" || url == "" { + skipped++ + continue + } + if sourceType == "" { + sourceType = "rtsp" + } + if resolution == "" { + resolution = "1920x1080" + } + if fps == "" { + fps = "25" + } + asset := service.ConfigVideoSourceAsset{ + Name: name, + SourceType: sourceType, + Area: area, + Config: service.VideoSourceConfig{ + URL: url, + Resolution: resolution, + FPS: fps, + }, + } + if err := u.preview.SaveVideoSourceAsset(asset); err != nil { + skipped++ + continue + } + imported++ + } + + msg := fmt.Sprintf("导入完成:%d 个视频源,%d 条跳过", imported, skipped) + http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape(msg), http.StatusFound) +} + func (u *UI) pageAssetIntegrations(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("integrations") data.Title = "配置中心" diff --git a/internal/web/ui/templates/assets.html b/internal/web/ui/templates/assets.html index 2055565..aeb5993 100644 --- a/internal/web/ui/templates/assets.html +++ b/internal/web/ui/templates/assets.html @@ -183,6 +183,8 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');