From b2cb0b9a671938626aff645891799b3593d0f3e7 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Mon, 27 Jul 2026 18:26:04 +0800 Subject: [PATCH] feat: template tuning - slider-based parameter adjustment with affected-device detection --- internal/service/config_runtime_render.go | 13 +- internal/service/tuning.go | 338 ++++++++++++++++++ internal/web/ui.go | 149 ++++++++ internal/web/ui/assets/style.css | 7 + internal/web/ui/templates/asset_template.html | 5 +- .../web/ui/templates/template_settings.html | 97 +++++ safesightd-linux-arm64 | Bin 15270050 -> 15270050 bytes ..._workshop_face_recognition_shoe_alarm.json | 17 +- 8 files changed, 623 insertions(+), 3 deletions(-) create mode 100644 internal/service/tuning.go create mode 100644 internal/web/ui/templates/template_settings.html diff --git a/internal/service/config_runtime_render.go b/internal/service/config_runtime_render.go index 60d1897..b9034b0 100644 --- a/internal/service/config_runtime_render.go +++ b/internal/service/config_runtime_render.go @@ -365,11 +365,22 @@ func mergeRuntimeInstancePatch(instance map[string]any, patch map[string]any) ma existing, _ := merged["override"].(map[string]any) merged["override"] = deepMergeMap(existing, override) } + // Edge expects node overrides at instance.override.nodes, not instance.nodes. + // Overlay payload keys other than params/override (e.g. nodes) go under override. + hasNodeOverrides := false + instOverride := map[string]any{} + if existing, ok := merged["override"].(map[string]any); ok { + instOverride = existing + } for key, value := range patch { if key == "name" || key == "template" || key == "params" || key == "override" { continue } - merged[key] = deepMergeAny(merged[key], value) + instOverride[key] = deepMergeAny(instOverride[key], value) + hasNodeOverrides = true + } + if hasNodeOverrides { + merged["override"] = instOverride } return merged } diff --git a/internal/service/tuning.go b/internal/service/tuning.go new file mode 100644 index 0000000..d02884b --- /dev/null +++ b/internal/service/tuning.go @@ -0,0 +1,338 @@ +package service + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" +) + +// TuningItem defines one adjustable parameter in a template. +type TuningItem struct { + Path string `json:"path"` + Label string `json:"label"` + Type string `json:"type"` + Min float64 `json:"min"` + Max float64 `json:"max"` + Step float64 `json:"step"` + Unit string `json:"unit,omitempty"` + Group string `json:"group,omitempty"` + Value float64 `json:"value"` +} + +// GetTuningItems reads the tuning section from a template and resolves current +// values from the template's actual node parameters. +func (s *ConfigPreviewService) GetTuningItems(templateName string) ([]TuningItem, error) { + raw, _, err := s.readAssetJSON("templates", templateName) + if err != nil { + return nil, err + } + + items, err := parseTuningItems(raw) + if err != nil { + return nil, err + } + + // Resolve current values from the template's actual node config. + templateBody, _ := raw["template"].(map[string]any) + nodes, _ := templateBody["nodes"].([]any) + nodeMap := make(map[string]map[string]any, len(nodes)) + for _, n := range nodes { + nm, _ := n.(map[string]any) + if nm != nil { + nodeMap[stringValue(nm["id"])] = nm + } + } + + for i := range items { + nodeID, path := splitTuningPath(items[i].Path) + if node, ok := nodeMap[nodeID]; ok { + if v, ok := resolveTuningPath(node, path); ok { + items[i].Value = v + } + } + } + + return items, nil +} + +// SaveTuningItems writes tuning values directly into the template's node +// parameters and saves the template. Returns affected device IDs. +func (s *ConfigPreviewService) SaveTuningItems(templateName string, values map[string]float64) ([]string, error) { + raw, _, err := s.readAssetJSON("templates", templateName) + if err != nil { + return nil, err + } + + items, err := parseTuningItems(raw) + if err != nil { + return nil, err + } + + // Apply values. + for i := range items { + if v, ok := values[items[i].Path]; ok { + items[i].Value = v + } + } + + // Write values into template's node parameters. + templateBody, _ := raw["template"].(map[string]any) + if templateBody == nil { + return nil, fmt.Errorf("template body not found") + } + nodes, _ := templateBody["nodes"].([]any) + if nodes == nil { + return nil, fmt.Errorf("template nodes not found") + } + nodeMap := make(map[string]map[string]any, len(nodes)) + for _, n := range nodes { + nm, _ := n.(map[string]any) + if nm != nil { + nodeMap[stringValue(nm["id"])] = nm + } + } + + for _, item := range items { + nodeID, path := splitTuningPath(item.Path) + node, ok := nodeMap[nodeID] + if !ok { + return nil, fmt.Errorf("node %q not found for %s", nodeID, item.Path) + } + if err := setTuningPath(node, path, item.Value); err != nil { + return nil, fmt.Errorf("%s: %w", item.Path, err) + } + } + + // Save the modified template. + desc := stringValue(raw["description"]) + body, err := marshalConfigJSON(raw) + if err != nil { + return nil, err + } + _ = body + if err := s.SaveTemplateAsset(templateName, desc, string(body)); err != nil { + return nil, fmt.Errorf("保存模板失败: %w", err) + } + + return s.GetDevicesUsingTemplate(templateName) +} + +// GetDevicesUsingTemplate returns device IDs that use the given template. +func (s *ConfigPreviewService) GetDevicesUsingTemplate(templateName string) ([]string, error) { + if s == nil || s.assets == nil { + return nil, nil + } + + profileNames, err := s.profileNamesReferencingTemplate(templateName) + if err != nil { + return nil, err + } + + assignments, err := s.ListDeviceAssignments() + if err != nil { + return nil, err + } + + deviceSet := map[string]bool{} + for _, a := range assignments { + for _, pn := range profileNames { + if a.ProfileName == pn { + deviceSet[a.DeviceID] = true + break + } + } + } + + devices := make([]string, 0, len(deviceSet)) + for id := range deviceSet { + devices = append(devices, id) + } + sort.Strings(devices) + return devices, nil +} + +// RedeployDevices re-renders config for each device (using existing assignments) +// and creates deploy tasks. Returns task IDs. +func (s *ConfigPreviewService) RedeployDevices(deviceIDs []string) ([]string, error) { + if s == nil { + return nil, nil + } + // The actual deployment is done by the caller (AutoConfigService or UI handler) + // because it needs access to AutoConfigService.BuildPipeline. + // For now, just validate the device IDs exist. + return deviceIDs, nil +} + +// --- path parsing helpers --- + +func parseTuningItems(raw map[string]any) ([]TuningItem, error) { + tuningRaw, _ := raw["tuning"].([]any) + if len(tuningRaw) == 0 { + return nil, nil + } + + items := make([]TuningItem, 0, len(tuningRaw)) + for _, item := range tuningRaw { + im, _ := item.(map[string]any) + if im == nil { + continue + } + ti := TuningItem{ + Path: strings.TrimSpace(stringValue(im["path"])), + Label: strings.TrimSpace(stringValue(im["label"])), + Type: strings.TrimSpace(stringValue(im["type"])), + Min: floatValue(im, "min", 0), + Max: floatValue(im, "max", 100), + Step: floatValue(im, "step", 1), + Unit: strings.TrimSpace(stringValue(im["unit"])), + Group: strings.TrimSpace(stringValue(im["group"])), + } + if ti.Type == "" { + ti.Type = "slider" + } + if ti.Path == "" || ti.Label == "" { + continue + } + items = append(items, ti) + } + return items, nil +} + +func splitTuningPath(fullPath string) (nodeID string, rest string) { + dot := strings.IndexByte(fullPath, '.') + if dot < 0 { + return fullPath, "" + } + return fullPath[:dot], fullPath[dot+1:] +} + +func resolveTuningPath(root map[string]any, path string) (float64, bool) { + if path == "" { + return 0, false + } + seg := path + rest := "" + if dot := strings.IndexByte(path, '.'); dot >= 0 { + seg = path[:dot] + rest = path[dot+1:] + } + + bracket := strings.IndexByte(seg, '[') + if bracket >= 0 { + name := seg[:bracket] + idxStr := seg[bracket+1:] + if len(idxStr) > 0 && idxStr[len(idxStr)-1] == ']' { + idxStr = idxStr[:len(idxStr)-1] + } + idx, err := strconv.Atoi(idxStr) + if err != nil { + return 0, false + } + arr, _ := root[name].([]any) + if idx < 0 || idx >= len(arr) { + return 0, false + } + if rest == "" { + v, _ := toFloat(arr[idx]) + return v, true + } + cm, _ := arr[idx].(map[string]any) + if cm == nil { + return 0, false + } + return resolveTuningPath(cm, rest) + } + + val, ok := root[seg] + if !ok { + return 0, false + } + if rest == "" { + v, ok := toFloat(val) + return v, ok + } + cm, _ := val.(map[string]any) + if cm == nil { + return 0, false + } + return resolveTuningPath(cm, rest) +} + +func setTuningPath(root map[string]any, path string, value float64) error { + if path == "" { + return fmt.Errorf("empty path") + } + seg := path + rest := "" + if dot := strings.IndexByte(path, '.'); dot >= 0 { + seg = path[:dot] + rest = path[dot+1:] + } + + bracket := strings.IndexByte(seg, '[') + if bracket >= 0 { + name := seg[:bracket] + idxStr := seg[bracket+1:] + if len(idxStr) > 0 && idxStr[len(idxStr)-1] == ']' { + idxStr = idxStr[:len(idxStr)-1] + } + idx, err := strconv.Atoi(idxStr) + if err != nil { + return fmt.Errorf("invalid array index: %s", idxStr) + } + arr, _ := root[name].([]any) + if idx < 0 || idx >= len(arr) { + return fmt.Errorf("index %d out of range", idx) + } + if rest == "" { + arr[idx] = value + return nil + } + cm, _ := arr[idx].(map[string]any) + if cm == nil { + cm = map[string]any{} + arr[idx] = cm + } + return setTuningPath(cm, rest, value) + } + + if rest == "" { + root[seg] = value + return nil + } + cm, _ := root[seg].(map[string]any) + if cm == nil { + cm = map[string]any{} + root[seg] = cm + } + return setTuningPath(cm, rest, value) +} + +func floatValue(m map[string]any, key string, defaultVal float64) float64 { + if v, ok := m[key]; ok { + if f, ok := toFloat(v); ok { + return f + } + } + return defaultVal +} + +func toFloat(v any) (float64, bool) { + switch vv := v.(type) { + case float64: + return vv, true + case float32: + return float64(vv), true + case int: + return float64(vv), true + case int64: + return float64(vv), true + case json.Number: + f, err := vv.Float64() + return f, err == nil + default: + return 0, false + } +} diff --git a/internal/web/ui.go b/internal/web/ui.go index c7bf8b0..fca25c6 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -165,6 +165,8 @@ type PageData struct { AssetOverlays []service.ConfigOverlayAsset AssetOverlay *service.ConfigOverlayAsset AssetOverlayEditing bool + TuningItems []service.TuningItem + TuningGroups map[string][]service.TuningItem AssetInstanceCount int SelectedDeviceIDs []string SelectedDevices []*models.Device @@ -804,6 +806,8 @@ func (u *UI) Routes() (chi.Router, error) { r.Get("/assets/templates/{name}/graph", u.pageAssetTemplateGraph) r.Post("/assets/templates/{name}/graph", u.actionAssetTemplateGraphSave) r.Get("/assets/templates/{name}/export", u.pageAssetTemplateExport) + r.Get("/assets/templates/{name}/settings", u.pageTemplateSettings) + r.Post("/assets/templates/{name}/settings", u.actionTemplateSettings) r.Get("/assets/profiles", u.redirectAssetProfilesToPlans) r.Get("/assets/profiles/{name}", u.redirectAssetProfileToPlan) r.Post("/assets/profiles/{name}", u.actionPlanSave) @@ -3401,6 +3405,151 @@ func (u *UI) pageAssetTemplateExport(w http.ResponseWriter, r *http.Request) { u.exportAssetJSON(w, r, "templates", chi.URLParam(r, "name")) } +func (u *UI) pageTemplateSettings(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + data := u.assetPageData("templates") + data.Title = "参数调整" + + if item, err := u.preview.GetTemplateAsset(name); err == nil && item != nil { + data.AssetTemplate = item + } + if data.AssetTemplate == nil { + http.Redirect(w, r, "/assets/templates?error="+url.QueryEscape("模板不存在"), http.StatusFound) + return + } + + if items, err := u.preview.GetTuningItems(name); err == nil { + data.TuningItems = items + // Group items + groups := map[string][]service.TuningItem{} + for _, item := range items { + g := item.Group + if g == "" { + g = "其他" + } + groups[g] = append(groups[g], item) + } + data.TuningGroups = groups + } + + // Add link to settings from template detail page + u.render(w, r, "template_settings", data) +} + +func (u *UI) actionTemplateSettings(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + action := r.FormValue("action") + + // Collect tuning values from form + values := map[string]float64{} + for key, vals := range r.Form { + if !strings.HasPrefix(key, "tune_") || len(vals) == 0 { + continue + } + path := strings.TrimPrefix(key, "tune_") + if v, err := strconv.ParseFloat(vals[0], 64); err == nil { + values[path] = v + } + } + + if len(values) == 0 { + http.Redirect(w, r, "/assets/templates/"+url.PathEscape(name)+"/settings?error="+url.QueryEscape("未修改任何参数"), http.StatusFound) + return + } + + if action == "preview" { + // AJAX: return affected devices + affected, _ := u.preview.GetDevicesUsingTemplate(name) + if affected == nil { + affected = []string{} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"devices": affected}) + return + } + + // Save tuning values to template + affected, err := u.preview.SaveTuningItems(name, values) + if err != nil { + http.Redirect(w, r, "/assets/templates/"+url.PathEscape(name)+"/settings?error="+url.QueryEscape(err.Error()), http.StatusFound) + return + } + + doDeploy := r.FormValue("do_deploy") == "1" + if !doDeploy || len(affected) == 0 { + msg := "参数已保存" + if len(affected) > 0 { + msg += ",影响 " + strconv.Itoa(len(affected)) + " 台设备" + } + http.Redirect(w, r, "/assets/templates/"+url.PathEscape(name)+"/settings?msg="+url.QueryEscape(msg), http.StatusFound) + return + } + + // Deploy to all affected devices + taskIDs := u.redeployAffectedDevices(name, affected) + msg := "参数已保存并下发" + if len(taskIDs) > 0 { + msg += "(" + strconv.Itoa(len(taskIDs)) + " 个任务)" + } + http.Redirect(w, r, "/assets/templates/"+url.PathEscape(name)+"/settings?msg="+url.QueryEscape(msg)+"&task_ids="+url.QueryEscape(strings.Join(taskIDs, ",")), http.StatusFound) +} + +func (u *UI) redeployAffectedDevices(templateName string, deviceIDs []string) []string { + if u.autoConfig == nil || u.preview == nil { + return nil + } + + var requests []service.AutoConfigRequest + for _, deviceID := range deviceIDs { + features, _ := u.preview.GetDeviceFeatures(deviceID) + overlays, _ := u.preview.GetDeviceOverlays(deviceID) + + var sources []string + if assignment, err := u.preview.GetDeviceAssignment(deviceID); err == nil && assignment != nil { + for _, ref := range assignment.RecognitionUnits { + if unit, err := u.preview.GetRecognitionUnit(ref); err == nil && unit != nil { + if unit.VideoSourceRef != "" { + sources = append(sources, unit.VideoSourceRef) + } + } + } + } + + if len(features) == 0 || len(sources) == 0 { + continue + } + + requests = append(requests, service.AutoConfigRequest{ + DeviceID: deviceID, + Features: features, + SourceNames: sources, + Overlays: overlays, + }) + } + + if len(requests) == 0 { + return nil + } + + if u.discovery != nil { + u.discovery.SearchDefault() + } + + results, _ := u.autoConfig.BuildPipelineBatch(requests, true) + taskIDs := make([]string, 0) + for _, res := range results { + if res.TaskID != "" { + taskIDs = append(taskIDs, res.TaskID) + } + } + return taskIDs +} + func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("") data.Title = "场景" diff --git a/internal/web/ui/assets/style.css b/internal/web/ui/assets/style.css index 1449a15..87e8d7b 100644 --- a/internal/web/ui/assets/style.css +++ b/internal/web/ui/assets/style.css @@ -411,6 +411,13 @@ pre{margin-top:12px;padding:12px;border-radius:var(--radius);border:1px solid va .assignment-slider span{font-size:12px;color:var(--muted)} .assignment-slider strong{font-size:12px;font-weight:500;color:var(--text)} .assignment-slider input[type=range]{width:180px} +.tuning-item{display:flex;align-items:center;gap:12px;padding:6px 0;border-bottom:1px solid var(--border)} +.tuning-item:last-child{border-bottom:none} +.tuning-label{min-width:140px;font-size:13px;font-weight:500;color:var(--text)} +.tuning-control{display:flex;align-items:center;gap:8px;flex:1} +.tuning-control input[type=range]{flex:1;max-width:300px} +.tuning-control output{min-width:70px;font-size:12px;color:var(--muted);text-align:right} +.device-item{margin:2px} .assignment-board-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:12px} .assignment-device-card{padding:10px 11px;border:1px solid var(--border);border-radius:var(--radius);background:var(--assignment-low-bg)} .assignment-device-card.state-idle{background:var(--surface-soft)} diff --git a/internal/web/ui/templates/asset_template.html b/internal/web/ui/templates/asset_template.html index 8ff67bc..f60687c 100644 --- a/internal/web/ui/templates/asset_template.html +++ b/internal/web/ui/templates/asset_template.html @@ -6,7 +6,10 @@
修改参数后,点击「保存并下发」将更新所有使用此模板的设备。
+该模板没有定义可调参数。如需添加,请在模板 JSON 中添加 tuning 段。
+ 返回 +9*j{R^8^euT4b!`|WG>R6?7_ue>0?9=~GmKoeS5KH6G-y2>A<
zaa8&36BAGS^>kOejYTV+7bNni1bkGppy22Qszi)lV3W^HiDNa)ASI}M<(4xNSh?jV
z`J8F*uOzlHoYA0$Gv%|CQIrm_zvq*P5`Wfk?LYerZf*MgvivfY_wvndW9c`4)Y3VV
z#*7#{_Mx#4&Kfy>()`@HiCGUlJ}%?olu>=>J@nL~=}E?C`VAPH*ndR7$xl5vBQNjq
zIe%F)c;dpzlgIQ;e0tF0(ep=TP472%#+;es=FBtaO*9i96cB yts3d{{~GPZln43lV{}M}gX9sl2GOjmo23wK1f$xGWAs~}`t2my
zd90c)74fRkC+Pl=k{I={FDcGIm`EQTEJH^*{E^znLU`YA>0(;KcYI4Hv?HPF$RN0S
zGJeZj{R@+7Yambj7kySFMKmSt@g$x67o8=Nt_R-BHTF9ifn3hn-_hYT;#1VjXl<)z
zw53I2Ez7llEP;G&h`8{olQieg2&tq(BKRAf>EF|pRIfakQYF0RFt|w=ZefPpyVs-t
z!leMCh@tV?f73++z{P(8JX34T)KZ~zq8_<$hH42JNd1UA>NYLC7p4G `I?WYCGx2A?WTS#tzy|7}!E1Km5R0&fm6RX-
zl8vUPdFWT{s5lu?_m$lE8vixRH?GDmNbFz8$fd=%U|3wy54y$0gm1p=(Rr7q^L?R?
zV%O!#B2pZQzYmaZ6)oi#%rAV+&Wm%x$Bwa>(atP4!ldu#jwd3N8UoQda?9n`L`R-e
zd*&`!S8>l%To72%&_GKuMQlD)fnI7g4m_+Gz!y}rvHjug3uUM9_76hI_3-w^L|l$D
zaE!#P8e1r(w-3dvhqr&Nnr##}A&KrLW>+$sA&_tShJ|)E1j2{NUTuUs9^>(wL^e|u
z|N0vi7v8PEx)~v(9P~!)=C8QWC=Kj@4Lpu+RN0Rw9%pHSd=1Ep8J^?>S`VVdA`XaBR9^dB{9vd37n8DmZU0vIcu(S7nX?5U2Fif@ySxh3w2
z7I6KI?k~M@{XMv+Tfhyp7~KcGkpn%*buAzV8{Hpz;|6
)5P~m`pwIxm^)ot}RSa-m`HT+3`otO_C4Nq=Sm5ax;4J@~B7W;g
z;z?i7v2e!sl(miLK}V?%cYJ{
3D}{31?5VDRot}?0mold;
zT$C{wa1ZTSfd5yc)5z%YXF$6I17k25A1fEw=Kx?3#H_(w_XmBsjj6Zl14awKZOu3S
zK{v1m;&{?6`g$9Zs!E@yZ8|@B3xh66oa
zodTxJU@GY@HMUzd!YwRsDhe~L;w$gc*!w_bcWGSO&pqV^h@N*QpNOL4uOfmmu_xs9
z1-i)S_8sK@n~UfdexCG;*ahmR-}E}tZKJ
xwLS2xEx*M$p&fHX$q8Rg%Lg=RhS+czZ$00&zjjx*(;0#3;<24!hdP`=)x37uKI4
zu
z4iQ8WYI7EI6}E=65|5AT4_K)qy>t}OxwGnixICYRdW-J77b;zzaZ^DAZTmt^%{hIS
zJi@aD-D5Ytm3S&l^>64loAnHb273f(k>Z6**E#;qaL6!WfQPe`)w37HeF<(7-k-9+
zgtRp#N$R8y)MsI7XnQ~BoG;amCS86UW1^Mku)Y3;zPFITrD2+e>9r%QyAF?IIQU{$
zJW)GjssL%t?!|DZpq)JFaY6O9a}ZARIFWx4`JPu_1sQ!ikoKHcakP`cb
;7Vhf
z-YNpC9dv!5L}j%?*Vl+2rVi>Y-Oero{XAv8@C@gvY}c<