feat: template tuning - slider-based parameter adjustment with affected-device detection
This commit is contained in:
parent
0895d19fe0
commit
b2cb0b9a67
@ -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
|
||||
}
|
||||
|
||||
338
internal/service/tuning.go
Normal file
338
internal/service/tuning.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
@ -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 = "场景"
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -6,7 +6,10 @@
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "template"}}<span>{{.AssetTemplate.Name}}</span></h2>
|
||||
</div>
|
||||
<a class="btn secondary" href="/assets/templates">返回模板列表</a>
|
||||
<div class="btn-group">
|
||||
<a class="btn primary" href="/assets/templates/{{.AssetTemplate.Name}}/settings">{{icon "edit"}} 参数调整</a>
|
||||
<a class="btn secondary" href="/assets/templates">返回模板列表</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div><span>模板名</span><strong class="mono">{{.AssetTemplate.Name}}</strong></div>
|
||||
|
||||
97
internal/web/ui/templates/template_settings.html
Normal file
97
internal/web/ui/templates/template_settings.html
Normal file
@ -0,0 +1,97 @@
|
||||
{{define "template_settings"}}
|
||||
{{template "asset_tabs" .}}
|
||||
{{if .TuningItems}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<h2 class="title-with-icon">{{icon "edit"}}<span>{{.AssetTemplate.Name}} · 参数调整</span></h2>
|
||||
</div>
|
||||
<p class="hint">修改参数后,点击「保存并下发」将更新所有使用此模板的设备。</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/assets/templates/{{.AssetTemplate.Name}}/settings">
|
||||
{{range $group, $items := .TuningGroups}}
|
||||
<div class="card">
|
||||
<h3 class="title-with-icon">{{icon "edit"}}<span>{{$group}}</span></h3>
|
||||
<div class="tuning-list">
|
||||
{{range $items}}
|
||||
<div class="tuning-item">
|
||||
<label class="tuning-label">{{.Label}}</label>
|
||||
<div class="tuning-control">
|
||||
<input type="range" name="tune_{{.Path}}" value="{{printf "%.4f" .Value}}" min="{{.Min}}" max="{{.Max}}" step="{{.Step}}"
|
||||
oninput="this.nextElementSibling.value=Number(this.value).toFixed({{if eq .Step 1.0}}0{{else if eq .Step 0.05}}2{{else}}1{{end}})+'{{.Unit}}'">
|
||||
<output>{{printf "%.4f" .Value}}{{.Unit}}</output>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card" id="affected-devices" style="display:none">
|
||||
<h3 class="title-with-icon">{{icon "device"}}<span>受影响设备 (<span id="affected-count">0</span>)</span></h3>
|
||||
<div id="affected-list" class="device-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="card actions">
|
||||
<button type="submit" class="btn primary" name="action" value="save">保存参数</button>
|
||||
<button type="submit" class="btn primary" name="action" value="deploy" id="btn-deploy" disabled>保存并下发</button>
|
||||
<a class="btn secondary" href="/assets/templates/{{.AssetTemplate.Name}}">返回</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.querySelector('form').addEventListener('submit', async function(e) {
|
||||
const btn = e.submitter;
|
||||
if (!btn || btn.value !== 'deploy') return;
|
||||
e.preventDefault();
|
||||
|
||||
const form = this;
|
||||
const fd = new FormData(form);
|
||||
fd.set('action', 'preview');
|
||||
|
||||
const resp = await fetch(form.action, { method: 'POST', body: fd });
|
||||
const data = await resp.json();
|
||||
|
||||
const div = document.getElementById('affected-devices');
|
||||
const count = document.getElementById('affected-count');
|
||||
const list = document.getElementById('affected-list');
|
||||
const deployBtn = document.getElementById('btn-deploy');
|
||||
|
||||
div.style.display = 'block';
|
||||
count.textContent = data.devices ? data.devices.length : 0;
|
||||
|
||||
if (data.devices && data.devices.length > 0) {
|
||||
deployBtn.disabled = false;
|
||||
list.innerHTML = data.devices.map(function(d) {
|
||||
return '<span class="pill ok device-item">' + d + '</span>';
|
||||
}).join('');
|
||||
} else {
|
||||
deployBtn.disabled = true;
|
||||
list.innerHTML = '<p class="hint">没有设备使用此模板。请先在运行看板中为设备分配此模板。</p>';
|
||||
}
|
||||
|
||||
// Store affected devices for final submit
|
||||
document.getElementById('affected-ids').value = (data.devices || []).join(',');
|
||||
document.getElementById('deploy-flag').value = '1';
|
||||
});
|
||||
|
||||
document.getElementById('btn-deploy').addEventListener('click', function() {
|
||||
if (document.getElementById('affected-ids').value) {
|
||||
document.getElementById('deploy-flag').value = '1';
|
||||
document.querySelector('form').submit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<input type="hidden" name="affected_ids" id="affected-ids" value="">
|
||||
<input type="hidden" name="do_deploy" id="deploy-flag" value="0">
|
||||
|
||||
{{else}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<h2 class="title-with-icon">{{icon "edit"}}<span>{{.AssetTemplate.Name}} · 参数调整</span></h2>
|
||||
</div>
|
||||
<p class="hint">该模板没有定义可调参数。如需添加,请在模板 JSON 中添加 tuning 段。</p>
|
||||
<a class="btn secondary" href="/assets/templates/{{.AssetTemplate.Name}}">返回</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
Binary file not shown.
@ -548,5 +548,20 @@
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"tuning": [
|
||||
{"path":"alarm_violation.rules[0].cooldown_ms","label":"告警冷却时间","type":"slider","min":5000,"max":300000,"step":1000,"unit":"ms","group":"告警"},
|
||||
{"path":"alarm_violation.rules[0].min_duration_ms","label":"最小持续时间","type":"slider","min":200,"max":10000,"step":100,"unit":"ms","group":"告警"},
|
||||
{"path":"alarm_violation.rules[0].min_hits","label":"最小命中帧数","type":"slider","min":1,"max":10,"step":1,"unit":"帧","group":"告警"},
|
||||
{"path":"alarm_violation.rules[0].min_score","label":"最低置信度","type":"slider","min":0.1,"max":0.9,"step":0.05,"group":"告警"},
|
||||
{"path":"alarm_violation.face_rules[0].cooldown_ms","label":"陌生人脸冷却","type":"slider","min":2000,"max":120000,"step":1000,"unit":"ms","group":"人脸"},
|
||||
{"path":"alarm_violation.face_rules[0].min_hits","label":"陌生人脸命中帧数","type":"slider","min":1,"max":10,"step":1,"unit":"帧","group":"人脸"},
|
||||
{"path":"alarm_violation.face_rules[0].max_known_sim","label":"陌生人脸相似度上限","type":"slider","min":0.1,"max":0.8,"step":0.05,"group":"人脸"},
|
||||
{"path":"alarm_violation.face_rules[1].cooldown_ms","label":"已知人员冷却","type":"slider","min":2000,"max":120000,"step":1000,"unit":"ms","group":"人脸"},
|
||||
{"path":"alarm_violation.face_rules[1].min_sim","label":"已知人员相似度阈值","type":"slider","min":0.3,"max":0.95,"step":0.05,"group":"人脸"},
|
||||
{"path":"alarm_violation.face_rules[1].min_hits","label":"已知人员命中帧数","type":"slider","min":1,"max":10,"step":1,"unit":"帧","group":"人脸"},
|
||||
{"path":"recognize_face.threshold.accept","label":"人脸识别阈值","type":"slider","min":0.3,"max":0.9,"step":0.05,"group":"人脸"},
|
||||
{"path":"detect_shoe.conf","label":"工鞋检测阈值","type":"slider","min":0.1,"max":0.8,"step":0.05,"group":"工鞋"},
|
||||
{"path":"detect_person.conf","label":"人体检测阈值","type":"slider","min":0.1,"max":0.8,"step":0.05,"group":"检测"}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user