From ce18c85ebad8568a64ada4c1b26f1761124291d8 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Sat, 9 May 2026 11:39:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A0=87=E5=87=86=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E5=8F=82=E6=95=B0=20&=20=E5=85=8B=E9=9A=86=E4=B8=8D=E7=AB=8B?= =?UTF-8?q?=E5=8D=B3=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增标准调试参数 - templates/standard_overlays/ 目录下导入 std_face_debug、 std_test_sensitive、std_production_quiet 三个标准调试参数 - 启动时通过 ImportStandardOverlaysFromDir 自动导入 SQLite - 支持 std_ 前缀只读保护,不可直接编辑/删除 2. 复制后不立即保存 - 调试参数克隆:重定向到新建页预填数据,点击保存才入库 - 识别模板克隆:重定向到模板编辑页预填,点击创建/进入可视化编辑才入库 - 可视化编辑也支持 clone_source 参数,编辑保存时创建新模板 3. 简化调试参数 JSON 结构 - 去掉 instance_overrides 层级,改为顶层 override 键 - 运行时渲染兼容新旧两种格式 - UI 去掉目标/目标数量字段 4. 清理死代码 - 移除从零新建空白调试参数/模板的代码路径 --- CLAUDE.md | 7 +- cmd/managerd/main.go | 5 + internal/service/config_assets.go | 14 + internal/service/config_runtime_render.go | 13 + internal/service/standard_templates.go | 66 +++++ internal/web/ui.go | 242 ++++++++++++------ internal/web/ui/templates/asset_overlays.html | 54 ++-- .../ui/templates/asset_template_graph.html | 3 +- .../web/ui/templates/asset_templates.html | 28 +- internal/web/ui_test.go | 13 +- .../standard_overlays/std_face_debug.json | 20 ++ .../std_production_quiet.json | 35 +++ .../standard_overlays/std_test_sensitive.json | 43 ++++ 13 files changed, 427 insertions(+), 116 deletions(-) create mode 100644 templates/standard_overlays/std_face_debug.json create mode 100644 templates/standard_overlays/std_production_quiet.json create mode 100644 templates/standard_overlays/std_test_sensitive.json diff --git a/CLAUDE.md b/CLAUDE.md index 0ba1565..1397c01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,10 +2,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Build & Test Commands +## Build, Test & Restart Commands -- Build: `go build -o managerd ./cmd/managerd` +- Build: `go build -o managerd ./cmd/managerd` 或 `pwsh -File scripts/managerd.ps1 build` - Run: `./managerd` (uses `managerd.json` config) or `./managerd ` +- 重启服务(编译+启停+健康检查一站式):`pwsh -File scripts/managerd.ps1 restart` +- 查看服务状态:`pwsh -File scripts/managerd.ps1 status` - Test all: `go test ./...` - Test single package: `go test ./internal/service/...` - Test single test: `go test ./internal/storage/... -run TestAssetsRepo_SaveTemplate` @@ -92,3 +94,4 @@ The config preview rendering pipeline: merge template + profile + overlays → r - **No `fetch()` in templates** for page data — all API calls must go through Go server-side handlers. JS is only for UI interactivity (modals, carousels, confirm dialogs). - **No inline `onclick` with complex quoting** — use `data-*` attributes + event delegation instead. - **Use `pwsh`** for PowerShell commands (v7.6.1 in PATH, not `powershell.exe`). +- **重启服务**:统一用 `pwsh -File scripts/managerd.ps1 restart`,它会先编译、停旧进程、再启动新进程并做健康检查。不要手动 kill + build + start。 diff --git a/cmd/managerd/main.go b/cmd/managerd/main.go index b730f18..4f78115 100644 --- a/cmd/managerd/main.go +++ b/cmd/managerd/main.go @@ -47,6 +47,11 @@ func main() { } else if imported > 0 { log.Printf("imported %d standard templates", imported) } + if imported, err := service.ImportStandardOverlaysFromDir(assetsRepo, filepath.Join("templates", "standard_overlays")); err != nil { + log.Fatalf("import standard overlays: %v", err) + } else if imported > 0 { + log.Printf("imported %d standard overlays", imported) + } standardModelsDir := filepath.Join("models", "standard_models") modelSvc := service.NewModelManagementService(modelsRepo) if err := modelSvc.SyncStandardModelsFromDirectory(standardModelsDir); err != nil { diff --git a/internal/service/config_assets.go b/internal/service/config_assets.go index 440bdac..9d85e38 100644 --- a/internal/service/config_assets.go +++ b/internal/service/config_assets.go @@ -65,6 +65,7 @@ type ConfigOverlayAsset struct { Name string `json:"name"` Path string `json:"path"` Description string `json:"description"` + ReadOnly bool `json:"read_only"` OverrideTargets []string `json:"override_targets"` OverrideTargetNum int `json:"override_target_num"` Raw map[string]any `json:"raw"` @@ -677,6 +678,12 @@ func (s *ConfigPreviewService) ListOverlayAssets() ([]ConfigOverlayAsset, error) } items = append(items, *item) } + sort.Slice(items, func(i, j int) bool { + if items[i].ReadOnly != items[j].ReadOnly { + return items[i].ReadOnly + } + return items[i].Name < items[j].Name + }) return items, nil } @@ -696,6 +703,7 @@ func (s *ConfigPreviewService) GetOverlayAsset(name string) (*ConfigOverlayAsset Name: name, Path: path, Description: stringValue(raw["description"]), + ReadOnly: isStandardTemplateName(name), OverrideTargets: targets, OverrideTargetNum: len(targets), Raw: raw, @@ -710,6 +718,9 @@ func (s *ConfigPreviewService) SaveOverlayAsset(asset ConfigOverlayAsset, raw ma if err := validateConfigName(name); err != nil { return err } + if isStandardTemplateName(name) { + return fmt.Errorf("标准调试参数 %q 为只读,请先复制后再编辑", name) + } if raw == nil { raw = map[string]any{} } @@ -729,6 +740,9 @@ func (s *ConfigPreviewService) DeleteOverlayAsset(name string) error { if err := validateConfigName(name); err != nil { return err } + if isStandardTemplateName(name) { + return fmt.Errorf("标准调试参数 %q 为只读,无法删除", name) + } refs, err := s.profileNamesReferencingOverlay(name) if err != nil { return err diff --git a/internal/service/config_runtime_render.go b/internal/service/config_runtime_render.go index b03c5cc..60d1897 100644 --- a/internal/service/config_runtime_render.go +++ b/internal/service/config_runtime_render.go @@ -292,6 +292,19 @@ func applyRuntimeOverlay(root map[string]any, overlay map[string]any) (map[strin out[key] = deepMergeAny(out[key], value) } } + if rawOverride, ok := overlay["override"]; ok { + if override, _ := rawOverride.(map[string]any); override != nil { + instances, _ := out["instances"].([]any) + mergedInstances := make([]any, 0, len(instances)) + for _, item := range instances { + instance, _ := item.(map[string]any) + merged := deepCopyMap(instance) + merged = mergeRuntimeInstancePatch(merged, override) + mergedInstances = append(mergedInstances, merged) + } + out["instances"] = mergedInstances + } + } if rawPatches, ok := overlay["instance_overrides"]; ok { patches, _ := rawPatches.(map[string]any) if patches == nil { diff --git a/internal/service/standard_templates.go b/internal/service/standard_templates.go index 052b37f..77aa910 100644 --- a/internal/service/standard_templates.go +++ b/internal/service/standard_templates.go @@ -75,3 +75,69 @@ func ImportStandardTemplatesFromDir(repo *storage.AssetsRepo, dir string) (int, } return imported, nil } + +func ImportStandardOverlaysFromDir(repo *storage.AssetsRepo, dir string) (int, error) { + if repo == nil { + return 0, fmt.Errorf("asset repository is not configured") + } + dir = filepath.Clean(strings.TrimSpace(dir)) + if dir == "" { + return 0, fmt.Errorf("standard overlay dir is empty") + } + files, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, err + } + imported := 0 + for _, file := range files { + if file.IsDir() || strings.ToLower(filepath.Ext(file.Name())) != ".json" { + continue + } + path := filepath.Join(dir, file.Name()) + body, err := os.ReadFile(path) + if err != nil { + return imported, err + } + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + return imported, fmt.Errorf("%s: %w", path, err) + } + if raw == nil { + raw = map[string]any{} + } + name := strings.TrimSpace(firstString(raw["name"], strings.TrimSuffix(file.Name(), filepath.Ext(file.Name())))) + if name == "" { + return imported, fmt.Errorf("%s: overlay name is empty", path) + } + if err := validateConfigName(name); err != nil { + return imported, fmt.Errorf("%s: invalid overlay name %q: %w", path, name, err) + } + if !isStandardTemplateName(name) { + return imported, fmt.Errorf("%s: standard overlay name must start with std_", path) + } + description := strings.TrimSpace(stringValue(raw["description"])) + delete(raw, "name") + delete(raw, "description") + body, err = marshalConfigJSON(raw) + if err != nil { + return imported, err + } + existing, err := repo.GetOverlay(name) + if err != nil { + return imported, err + } + if existing != nil && + strings.TrimSpace(existing.Description) == description && + strings.TrimSpace(existing.BodyJSON) == strings.TrimSpace(string(body)) { + continue + } + if err := repo.SaveOverlay(name, description, string(body)); err != nil { + return imported, err + } + imported++ + } + return imported, nil +} diff --git a/internal/web/ui.go b/internal/web/ui.go index 7918d3a..85843cc 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -127,6 +127,8 @@ type PageData struct { TemplateDraftName string TemplateDraftDescription string TemplateCloneSource string + TemplateCloneName string + TemplateCloneDesc string TemplateCreateMode string OverlayDraftJSON string AuditEntries []storage.AuditLogRecord @@ -617,6 +619,7 @@ func (u *UI) Routes() (chi.Router, error) { r.Post("/assets/integrations/{name}/delete", u.actionAssetIntegrationDelete) r.Get("/assets/overlays", u.pageAssetOverlays) r.Post("/assets/overlays", u.actionAssetOverlaySave) + r.Post("/assets/overlays/{name}/clone", u.actionAssetOverlayClone) r.Post("/assets/overlays/{name}/delete", u.actionAssetOverlayDelete) r.Get("/assets/overlays/{name}", u.pageAssetOverlay) r.Get("/assets/overlays/{name}/export", u.pageAssetOverlayExport) @@ -1586,6 +1589,9 @@ func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" + cloneSource := strings.TrimSpace(r.URL.Query().Get("clone_source")) + cloneName := strings.TrimSpace(r.URL.Query().Get("clone_name")) + cloneDesc := strings.TrimSpace(r.URL.Query().Get("clone_desc")) if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { if item, err := u.preview.GetTemplateAsset(name); err == nil { data.AssetTemplate = item @@ -1599,7 +1605,30 @@ func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) { data.Error = err.Error() } } - data.AssetTemplateEditing = editMode && data.AssetTemplate != nil && !data.AssetTemplate.ReadOnly + if cloneSource != "" && cloneName != "" { + data.TemplateCloneSource = cloneSource + data.TemplateCloneName = cloneName + data.TemplateCloneDesc = cloneDesc + if source, err := u.preview.GetTemplateAsset(cloneSource); err == nil { + body, _ := json.Marshal(source.Raw) + doc := map[string]any{} + if json.Unmarshal(body, &doc) == nil { + desc := cloneDesc + if desc == "" { + desc = source.Description + } + doc["name"] = cloneName + doc["description"] = desc + data.AssetTemplate = &service.ConfigTemplateAsset{ + Name: cloneName, + Description: desc, + Raw: doc, + ReadOnly: false, + } + } + } + } + data.AssetTemplateEditing = (editMode || cloneSource != "") && data.AssetTemplate != nil && !data.AssetTemplate.ReadOnly u.render(w, r, "asset_templates", data) } @@ -1617,37 +1646,29 @@ func (u *UI) actionAssetTemplateCreate(w http.ResponseWriter, r *http.Request) { description := strings.TrimSpace(r.FormValue("description")) cloneSource := strings.TrimSpace(r.FormValue("clone_source")) + if cloneSource == "" { + http.Error(w, "create template requires clone_source", http.StatusBadRequest) + return + } var doc map[string]any - if cloneSource != "" { - item, err := u.preview.GetTemplateAsset(cloneSource) - if err != nil || item == nil { - http.Error(w, "clone source template not found", http.StatusNotFound) - return - } - body, err := json.Marshal(item.Raw) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if err := json.Unmarshal(body, &doc); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } else { - doc = map[string]any{ - "name": name, - "description": description, - "params": map[string]any{}, - "template": map[string]any{ - "nodes": []any{}, - "edges": []any{}, - }, - } + item, err := u.preview.GetTemplateAsset(cloneSource) + if err != nil || item == nil { + http.Error(w, "clone source template not found", http.StatusNotFound) + return + } + body, err := json.Marshal(item.Raw) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := json.Unmarshal(body, &doc); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return } doc["name"] = name doc["description"] = description - body, err := json.MarshalIndent(doc, "", " ") + body, err = json.MarshalIndent(doc, "", " ") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -1660,7 +1681,7 @@ func (u *UI) actionAssetTemplateCreate(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), status) return } - http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(name)+"/graph?msg="+urlQueryEscape("模板已创建"), http.StatusFound) + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(name)+"&edit=1&msg="+urlQueryEscape("模板已创建"), http.StatusFound) } func (u *UI) actionAssetTemplateClone(w http.ResponseWriter, r *http.Request) { @@ -1683,28 +1704,10 @@ func (u *UI) actionAssetTemplateClone(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) return } - body, err := json.Marshal(item.Raw) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - doc := map[string]any{} - if err := json.Unmarshal(body, &doc); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - doc["name"] = targetName - doc["description"] = item.Description - pretty, err := json.MarshalIndent(doc, "", " ") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if err := u.preview.SaveTemplateAsset(targetName, item.Description, string(pretty)+"\n"); err != nil { - http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) - return - } - http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(targetName)+"&edit=1&msg="+urlQueryEscape("标准模板已复制,请继续编辑"), http.StatusFound) + cloneSource := url.QueryEscape(sourceName) + cloneName := url.QueryEscape(targetName) + cloneDesc := url.QueryEscape(item.Description) + http.Redirect(w, r, "/ui/assets/templates?clone_source="+cloneSource+"&clone_name="+cloneName+"&clone_desc="+cloneDesc, http.StatusFound) } func (u *UI) nextTemplateCloneName(sourceName string) (string, error) { @@ -1747,6 +1750,7 @@ func (u *UI) pageAssetTemplate(w http.ResponseWriter, r *http.Request) { func (u *UI) pageAssetTemplateGraph(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") + cloneSource := strings.TrimSpace(r.URL.Query().Get("clone_source")) data := u.assetPageData("templates") data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) if data.Error == "" { @@ -1757,17 +1761,41 @@ func (u *UI) pageAssetTemplateGraph(w http.ResponseWriter, r *http.Request) { u.render(w, r, "asset_templates", data) return } - item, err := u.preview.GetTemplateAsset(name) - if err != nil { - http.NotFound(w, r) - return + if cloneSource != "" { + source, err := u.preview.GetTemplateAsset(cloneSource) + if err != nil { + http.NotFound(w, r) + return + } + body, _ := json.Marshal(source.Raw) + doc := map[string]any{} + if json.Unmarshal(body, &doc) != nil { + http.NotFound(w, r) + return + } + doc["name"] = name + doc["description"] = source.Description + data.Title = "模板可视化编辑(从 " + cloneSource + " 克隆)" + data.AssetTemplate = &service.ConfigTemplateAsset{ + Name: name, + Description: source.Description, + Raw: doc, + ReadOnly: false, + } + data.TemplateCloneSource = cloneSource + } else { + item, err := u.preview.GetTemplateAsset(name) + if err != nil { + http.NotFound(w, r) + return + } + data.Title = "模板可视化编辑" + if item.ReadOnly { + data.Title = "标准模板可视化预览" + } + data.AssetTemplate = item } - data.Title = "模板可视化编辑" - if item.ReadOnly { - data.Title = "标准模板可视化预览" - } - data.AssetTemplate = item - raw, err := compactJSON(item.Raw) + raw, err := compactJSON(data.AssetTemplate.Raw) if err != nil { data.Error = err.Error() u.render(w, r, "asset_templates", data) @@ -1779,6 +1807,7 @@ func (u *UI) pageAssetTemplateGraph(w http.ResponseWriter, r *http.Request) { func (u *UI) actionAssetTemplateGraphSave(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") + cloneSource := strings.TrimSpace(r.FormValue("clone_source")) if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) return @@ -1817,8 +1846,12 @@ func (u *UI) actionAssetTemplateGraphSave(w http.ResponseWriter, r *http.Request http.Error(w, err.Error(), http.StatusInternalServerError) return } - if targetName != name { - err = u.preview.RenameTemplateAsset(name, targetName, description, string(body)+"\n") + if cloneSource != "" || targetName != name { + if cloneSource != "" { + err = u.preview.SaveTemplateAsset(targetName, description, string(body)+"\n") + } else { + err = u.preview.RenameTemplateAsset(name, targetName, description, string(body)+"\n") + } } else { err = u.preview.SaveTemplateAsset(name, description, string(body)+"\n") } @@ -2497,7 +2530,9 @@ func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) { } newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" - if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { + cloneSource := strings.TrimSpace(r.URL.Query().Get("clone_source")) + cloneName := strings.TrimSpace(r.URL.Query().Get("clone_name")) + if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" && !newMode { if item, err := u.preview.GetOverlayAsset(name); err == nil { data.AssetOverlay = item } else if data.Error == "" { @@ -2510,19 +2545,31 @@ func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) { data.Error = err.Error() } } - if newMode { - data.AssetOverlay = &service.ConfigOverlayAsset{ - Name: "", - Description: "", - Raw: map[string]any{ - "name": "", - "description": "", - "instance_overrides": map[string]any{"*": map[string]any{"override": map[string]any{}}}, - }, + if newMode && cloneSource != "" { + if item, err := u.preview.GetOverlayAsset(cloneSource); err == nil { + rawJSON, err := json.Marshal(item.Raw) + if err == nil { + doc := map[string]any{} + if err := json.Unmarshal(rawJSON, &doc); err == nil { + doc["name"] = cloneName + doc["description"] = item.Description + data.AssetOverlay = &service.ConfigOverlayAsset{ + Name: cloneName, + Description: item.Description, + Raw: doc, + } + } + } } - data.AssetOverlayEditing = true + if data.AssetOverlay == nil { + newMode = false + data.Error = "无法加载源调试参数" + } + } + if newMode { + data.AssetOverlayEditing = data.AssetOverlay != nil } else { - data.AssetOverlayEditing = editMode && data.AssetOverlay != nil + data.AssetOverlayEditing = editMode && data.AssetOverlay != nil && !data.AssetOverlay.ReadOnly } if data.AssetOverlay != nil { rawJSON, err := compactJSON(data.AssetOverlay.Raw) @@ -2586,6 +2633,53 @@ func (u *UI) actionAssetOverlaySave(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/assets/overlays?msg="+urlQueryEscape("调试参数已保存")+"&name="+url.PathEscape(name), http.StatusFound) } +func (u *UI) actionAssetOverlayClone(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + sourceName := chi.URLParam(r, "name") + item, err := u.preview.GetOverlayAsset(sourceName) + if err != nil || item == nil { + http.NotFound(w, r) + return + } + if !item.ReadOnly { + http.Redirect(w, r, "/ui/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准调试参数复制创建"), http.StatusFound) + return + } + targetName, err := u.nextOverlayCloneName(item.Name) + if err != nil { + http.Redirect(w, r, "/ui/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + cloneSource := url.QueryEscape(sourceName) + cloneName := url.QueryEscape(targetName) + http.Redirect(w, r, "/ui/assets/overlays?new=1&clone_source="+cloneSource+"&clone_name="+cloneName, http.StatusFound) +} + +func (u *UI) nextOverlayCloneName(sourceName string) (string, error) { + base := strings.TrimSpace(sourceName) + if base == "" { + return "", fmt.Errorf("调试参数名称不能为空") + } + if strings.HasPrefix(base, "std_") { + base = strings.TrimPrefix(base, "std_") + } + candidate := base + "_copy" + if item, err := u.preview.GetOverlayAsset(candidate); err == nil && item != nil { + for i := 2; i < 1000; i++ { + name := fmt.Sprintf("%s_copy_%d", base, i) + item, err := u.preview.GetOverlayAsset(name) + if err != nil || item == nil { + return name, nil + } + } + return "", fmt.Errorf("无法生成可用的调试参数副本名称") + } + return candidate, nil +} + func (u *UI) actionAssetOverlayDelete(w http.ResponseWriter, r *http.Request) { if u.preview == nil { http.Error(w, "config preview service is not configured", http.StatusInternalServerError) diff --git a/internal/web/ui/templates/asset_overlays.html b/internal/web/ui/templates/asset_overlays.html index 5b0a946..ecd84c1 100644 --- a/internal/web/ui/templates/asset_overlays.html +++ b/internal/web/ui/templates/asset_overlays.html @@ -4,14 +4,20 @@

{{icon "overlay"}}调试参数列表

+
标准调试参数应作为基线,复制后再做定制化修改。
- {{icon "apply"}}新增调试参数 {{if .AssetOverlay}} - {{icon "edit"}}编辑 -
- -
+ {{if .AssetOverlay.ReadOnly}} +
+ +
+ {{else}} + {{icon "edit"}}编辑 +
+ +
+ {{end}} {{end}}
@@ -21,7 +27,7 @@ 调试参数 描述 - 目标 + 来源 @@ -29,7 +35,7 @@ {{.Name}} {{if .Description}}{{.Description}}{{else}}-{{end}} - {{if .OverrideTargets}}{{range $i, $item := .OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}} + {{if .ReadOnly}}标准参数{{else}}用户参数{{end}} {{else}}
还没有调试参数
@@ -40,7 +46,6 @@ {{if .AssetOverlay}} -
@@ -65,34 +70,39 @@
{{if .AssetOverlayEditing}} -
- - - - -
-
- - {{icon "close"}}取消 -
+ +
+ + + +
+
+ + {{icon "close"}}取消 +
+ {{else}}
调试参数{{.AssetOverlay.Name}}
-
目标数量{{.AssetOverlay.OverrideTargetNum}}
+
参数类型{{if .AssetOverlay.ReadOnly}}标准参数(只读){{else}}用户参数{{end}}
描述{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}
-
作用目标{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}
路径{{.AssetOverlay.Path}}
{{end}}
-{{if not .AssetOverlayEditing}} +{{if and (not .AssetOverlayEditing) .AssetOverlay.ReadOnly}} +
+
标准调试参数保留在模板目录中,作为只读基线使用。需要调整参数时,请先复制为用户参数,再进行编辑保存。
+
+{{end}} + +{{if not .AssetOverlayEditing}}
{{icon "tech"}}原始 JSON
{{json .AssetOverlay.Raw}}
{{end}} - {{end}} {{if .Error}}
{{.Error}}
{{end}} {{template "asset_tabs_end" .}} diff --git a/internal/web/ui/templates/asset_template_graph.html b/internal/web/ui/templates/asset_template_graph.html index d3efb4b..e323604 100644 --- a/internal/web/ui/templates/asset_template_graph.html +++ b/internal/web/ui/templates/asset_template_graph.html @@ -10,13 +10,14 @@ {{if not .AssetTemplate.ReadOnly}}
+ {{if .TemplateCloneSource}}{{end}}
{{end}} {{if .AssetTemplate.ReadOnly}} {{icon "add"}}复制后编辑 {{end}} - 返回模板 + 返回模板 diff --git a/internal/web/ui/templates/asset_templates.html b/internal/web/ui/templates/asset_templates.html index 05f6454..617860e 100644 --- a/internal/web/ui/templates/asset_templates.html +++ b/internal/web/ui/templates/asset_templates.html @@ -8,7 +8,8 @@
{{if .AssetTemplate}} - {{if .AssetTemplate.ReadOnly}} + {{if .TemplateCloneSource}} + {{else if .AssetTemplate.ReadOnly}}
@@ -51,11 +52,11 @@
-

{{icon "template"}}{{if .AssetTemplateEditing}}模板编辑{{else}}模板详情{{end}}{{if .AssetTemplate.Name}} · {{.AssetTemplate.Name}}{{end}}

+

{{icon "template"}}{{if .TemplateCloneSource}}从标准模板克隆{{else if .AssetTemplateEditing}}模板编辑{{else}}模板详情{{end}}{{if .AssetTemplate.Name}} · {{.AssetTemplate.Name}}{{end}}

{{if .AssetTemplateEditing}} 编辑模式 - 当前模板基础信息正在编辑,结构修改请进入可视化编辑。 + {{if .TemplateCloneSource}}当前为标准模板克隆预览,保存后将创建为用户模板。{{else}}当前模板基础信息正在编辑,结构修改请进入可视化编辑。{{end}} {{else}} 查看模式 当前模板为只读详情展示。 @@ -63,22 +64,28 @@
{{if .AssetTemplateEditing}} -
+
+ {{if .TemplateCloneSource}} + + {{end}} + {{if not .TemplateCloneSource}}
来源文件{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}
节点数{{.AssetTemplate.NodeCount}}
@@ -88,9 +95,10 @@
输出槽位{{if .AssetTemplate.Slots.Outputs}}{{len .AssetTemplate.Slots.Outputs}}{{else}}0{{end}}
路径{{.AssetTemplate.Path}}
+ {{end}}
- - {{icon "close"}}取消 + + {{icon "close"}}取消
{{else}} @@ -115,7 +123,7 @@ {{end}}
-{{if or .AssetTemplate.Slots.Inputs .AssetTemplate.Slots.Services .AssetTemplate.Slots.Outputs}} +{{if and (not .TemplateCloneSource) (or .AssetTemplate.Slots.Inputs .AssetTemplate.Slots.Services .AssetTemplate.Slots.Outputs)}}
@@ -136,13 +144,13 @@
{{end}} -{{if .AssetTemplate.ReadOnly}} +{{if and (not .TemplateCloneSource) .AssetTemplate.ReadOnly}}
标准模板保留在模板目录中,作为只读基线使用。需要调整流程时,请先复制为用户模板,再进入可视化编辑保存。
{{end}} -{{if .AssetTemplate.AdvancedParams}} +{{if and (not .TemplateCloneSource) .AssetTemplate.AdvancedParams}}
{{icon "tech"}}高级设置
{{json .AssetTemplate.AdvancedParams}}
diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go index bdc9b42..40c2288 100644 --- a/internal/web/ui_test.go +++ b/internal/web/ui_test.go @@ -4044,7 +4044,7 @@ func TestUI_AssetTemplatePageShowsEditAndDeleteForUserTemplate(t *testing.T) { } } -func TestUI_ActionAssetTemplateCloneCreatesDefaultCopyAndRedirectsToEdit(t *testing.T) { +func TestUI_ActionAssetTemplateCloneRedirectsWithCloneSource(t *testing.T) { ui := newTestUI(t) store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) if err != nil { @@ -4068,17 +4068,16 @@ func TestUI_ActionAssetTemplateCloneCreatesDefaultCopyAndRedirectsToEdit(t *test if rr.Code != http.StatusFound { t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String()) } - if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates?name=face_recognition_stream_copy&edit=1") { - t.Fatalf("expected edit redirect, got %q", got) + wantLocation := "/ui/assets/templates?clone_source=std_face_recognition_stream&clone_name=face_recognition_stream_copy&clone_desc=helmet+template" + if got := rr.Header().Get("Location"); got != wantLocation { + t.Fatalf("expected redirect to %q, got %q", wantLocation, got) } saved, err := repo.GetTemplate("face_recognition_stream_copy") if err != nil { t.Fatalf("GetTemplate: %v", err) } - for _, want := range []string{`"name": "face_recognition_stream_copy"`, `"description": "helmet template"`, `"type": "input_rtsp"`, `"mode": "std"`} { - if saved == nil || !strings.Contains(saved.BodyJSON, want) { - t.Fatalf("expected cloned template to contain %q, got %#v", want, saved) - } + if saved != nil { + t.Fatal("expected clone template NOT to be saved until form submission") } } diff --git a/templates/standard_overlays/std_face_debug.json b/templates/standard_overlays/std_face_debug.json new file mode 100644 index 0000000..222f1a4 --- /dev/null +++ b/templates/standard_overlays/std_face_debug.json @@ -0,0 +1,20 @@ +{ + "description": "开启人脸识别详细调试日志和未匹配候选输出,用于联调和测试。", + "override": { + "nodes": { + "recognize_face": { + "debug": { + "enabled": true, + "log_matches": true, + "min_log_interval_ms": 0 + } + }, + "alarm_violation": { + "face_debug": { + "log_unknown_candidates": true, + "unknown_candidate_interval_ms": 0 + } + } + } + } +} diff --git a/templates/standard_overlays/std_production_quiet.json b/templates/standard_overlays/std_production_quiet.json new file mode 100644 index 0000000..9c1a8be --- /dev/null +++ b/templates/standard_overlays/std_production_quiet.json @@ -0,0 +1,35 @@ +{ + "description": "关闭所有调试日志和统计输出,适用于正式生产运行。", + "override": { + "nodes": { + "recognize_face": { + "debug": { + "enabled": false, + "log_matches": false + } + }, + "alarm_violation": { + "face_debug": { + "log_unknown_candidates": false + } + }, + "detect_person": { + "debug": { + "stats": false, + "detections": false + } + }, + "detect_face": { + "debug": { + "stats": false + } + }, + "rule_shoe_association": { + "debug": false + }, + "rule_shoe_color": { + "debug": false + } + } + } +} diff --git a/templates/standard_overlays/std_test_sensitive.json b/templates/standard_overlays/std_test_sensitive.json new file mode 100644 index 0000000..c36b105 --- /dev/null +++ b/templates/standard_overlays/std_test_sensitive.json @@ -0,0 +1,43 @@ +{ + "description": "降低告警阈值用于测试:缩短冷却时间、降低最小持续时间和命中数,适用于短时验证视频。", + "override": { + "nodes": { + "alarm_violation": { + "rules": [ + { + "name": "non_compliant_workshoe", + "class_ids": [2], + "min_score": 0.1, + "min_duration_ms": 100, + "min_hits": 1, + "hit_window_ms": 5000, + "cooldown_ms": 2000 + } + ], + "face_rules": [ + { + "name": "unknown_face", + "type": "unknown", + "cooldown_ms": 2000, + "min_hits": 1, + "hit_window_ms": 5000, + "min_face_area_ratio": 0.0001, + "min_face_aspect": 0.3, + "max_face_aspect": 3.0 + }, + { + "name": "known_person", + "type": "person", + "cooldown_ms": 2000, + "min_sim": 0.3, + "min_hits": 1, + "hit_window_ms": 5000, + "min_face_area_ratio": 0.0001, + "min_face_aspect": 0.3, + "max_face_aspect": 3.0 + } + ] + } + } + } +}