feat: 标准调试参数 & 克隆不立即保存

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. 清理死代码
   - 移除从零新建空白调试参数/模板的代码路径
This commit is contained in:
tian 2026-05-09 11:39:02 +08:00
parent 7386969c27
commit ce18c85eba
13 changed files with 427 additions and 116 deletions

View File

@ -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 <config-path>`
- 重启服务(编译+启停+健康检查一站式):`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。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,14 +4,20 @@
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "overlay"}}<span>调试参数列表</span></h2>
<div class="form-hint">标准调试参数应作为基线,复制后再做定制化修改。</div>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/assets/overlays?new=1">{{icon "apply"}}<span>新增调试参数</span></a>
{{if .AssetOverlay}}
<a class="btn secondary" href="/ui/assets/overlays?name={{.AssetOverlay.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
<form method="post" action="/ui/assets/overlays/{{.AssetOverlay.Name}}/delete">
<button class="btn secondary" type="submit">删除</button>
</form>
{{if .AssetOverlay.ReadOnly}}
<form method="post" action="/ui/assets/overlays/{{.AssetOverlay.Name}}/clone">
<button type="submit" class="btn secondary">{{icon "add"}}<span>复制标准调试参数</span></button>
</form>
{{else}}
<a class="btn secondary" href="/ui/assets/overlays?name={{.AssetOverlay.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
<form method="post" action="/ui/assets/overlays/{{.AssetOverlay.Name}}/delete" onsubmit="return confirm('确认删除这个调试参数吗?');">
<button type="submit" class="btn secondary">删除</button>
</form>
{{end}}
{{end}}
</div>
</div>
@ -21,7 +27,7 @@
<tr>
<th>调试参数</th>
<th>描述</th>
<th>目标</th>
<th>来源</th>
</tr>
</thead>
<tbody>
@ -29,7 +35,7 @@
<tr data-nav-row data-nav-href="/ui/assets/overlays?name={{.Name}}"{{if and $.AssetOverlay (eq $.AssetOverlay.Name .Name)}} class="selected"{{end}}>
<td><a class="mono" href="/ui/assets/overlays?name={{.Name}}">{{.Name}}</a></td>
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
<td>{{if .OverrideTargets}}{{range $i, $item := .OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</td>
<td>{{if .ReadOnly}}标准参数{{else}}用户参数{{end}}</td>
</tr>
{{else}}
<tr><td colspan="3"><div class="empty-state compact"><div class="empty-title">还没有调试参数</div></div></td></tr>
@ -40,7 +46,6 @@
</div>
{{if .AssetOverlay}}
<form method="post" action="/ui/assets/overlays">
<div class="card editor-state {{if .AssetOverlayEditing}}editing{{else}}readonly{{end}}">
<div class="section-title">
<div>
@ -65,34 +70,39 @@
</div>
</div>
{{if .AssetOverlayEditing}}
<div class="field-grid">
<label><span>调试参数名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetOverlay.Name}}" autofocus /></label>
<label><span>目标数量</span><input value="{{.AssetOverlay.OverrideTargetNum}}" readonly /></label>
<label class="full"><span>描述</span><input name="description" value="{{.AssetOverlay.Description}}" /></label>
<label class="full"><span>调试参数 JSON<span class="required-mark">*</span></span><textarea class="code-input" name="json">{{.OverlayDraftJSON}}</textarea></label>
</div>
<div class="actions">
<button type="submit" class="btn secondary">{{icon "apply"}}<span>保存</span></button>
<a class="btn secondary" href="/ui/assets/overlays{{if .AssetOverlay.Name}}?name={{.AssetOverlay.Name}}{{end}}">{{icon "close"}}<span>取消</span></a>
</div>
<form method="post" action="/ui/assets/overlays">
<div class="field-grid">
<label><span>调试参数名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetOverlay.Name}}" autofocus /></label>
<label class="full"><span>描述</span><input name="description" value="{{.AssetOverlay.Description}}" /></label>
<label class="full"><span>调试参数 JSON<span class="required-mark">*</span></span><textarea class="code-input" name="json">{{.OverlayDraftJSON}}</textarea></label>
</div>
<div class="actions">
<button type="submit" class="btn secondary">{{icon "apply"}}<span>保存</span></button>
<a class="btn secondary" href="/ui/assets/overlays">{{icon "close"}}<span>取消</span></a>
</div>
</form>
{{else}}
<div class="detail-sheet">
<div class="detail-item"><span>调试参数</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>
<div class="detail-item"><span>目标数量</span><strong>{{.AssetOverlay.OverrideTargetNum}}</strong></div>
<div class="detail-item"><span>参数类型</span><strong>{{if .AssetOverlay.ReadOnly}}标准参数(只读){{else}}用户参数{{end}}</strong></div>
<div class="detail-item full"><span>描述</span><strong>{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}</strong></div>
<div class="detail-item full"><span>作用目标</span><strong>{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</strong></div>
<div class="detail-item full"><span>路径</span><strong class="mono">{{.AssetOverlay.Path}}</strong></div>
</div>
{{end}}
</div>
{{if not .AssetOverlayEditing}}
{{if and (not .AssetOverlayEditing) .AssetOverlay.ReadOnly}}
<div class="card">
<div class="form-hint">标准调试参数保留在模板目录中,作为只读基线使用。需要调整参数时,请先复制为用户参数,再进行编辑保存。</div>
</div>
{{end}}
{{if not .AssetOverlayEditing}}
<details class="card collapsible">
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>
<pre>{{json .AssetOverlay.Raw}}</pre>
</details>
{{end}}
</form>
{{end}}
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{template "asset_tabs_end" .}}

View File

@ -10,13 +10,14 @@
{{if not .AssetTemplate.ReadOnly}}
<form class="graph-save-form" method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/graph">
<input type="hidden" name="json" />
{{if .TemplateCloneSource}}<input type="hidden" name="clone_source" value="{{.TemplateCloneSource}}" />{{end}}
<button type="submit" class="primary">{{icon "apply"}}<span>保存模板</span></button>
</form>
{{end}}
{{if .AssetTemplate.ReadOnly}}
<a class="btn secondary" href="/ui/assets/templates?clone={{.AssetTemplate.Name}}">{{icon "add"}}<span>复制后编辑</span></a>
{{end}}
<a class="btn ghost" href="/ui/assets/templates?name={{.AssetTemplate.Name}}">返回模板</a>
<a class="btn ghost" href="/ui/assets/templates{{if .TemplateCloneSource}}?clone_source={{.TemplateCloneSource}}&clone_name={{.AssetTemplate.Name}}{{else}}?name={{.AssetTemplate.Name}}{{end}}">返回模板</a>
</div>
</div>

View File

@ -8,7 +8,8 @@
</div>
<div class="actions compact">
{{if .AssetTemplate}}
{{if .AssetTemplate.ReadOnly}}
{{if .TemplateCloneSource}}
{{else if .AssetTemplate.ReadOnly}}
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/clone">
<button type="submit" class="btn secondary">{{icon "add"}}<span>复制标准模板</span></button>
</form>
@ -51,11 +52,11 @@
<div class="card editor-state {{if .AssetTemplateEditing}}editing{{else}}readonly{{end}}">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "template"}}<span>{{if .AssetTemplateEditing}}模板编辑{{else}}模板详情{{end}}{{if .AssetTemplate.Name}} · {{.AssetTemplate.Name}}{{end}}</span></h2>
<h2 class="title-with-icon">{{icon "template"}}<span>{{if .TemplateCloneSource}}从标准模板克隆{{else if .AssetTemplateEditing}}模板编辑{{else}}模板详情{{end}}{{if .AssetTemplate.Name}} · {{.AssetTemplate.Name}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .AssetTemplateEditing}}
<span class="pill run">编辑模式</span>
<span>当前模板基础信息正在编辑,结构修改请进入可视化编辑。</span>
<span>{{if .TemplateCloneSource}}当前为标准模板克隆预览,保存后将创建为用户模板。{{else}}当前模板基础信息正在编辑,结构修改请进入可视化编辑。{{end}}</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前模板为只读详情展示。</span>
@ -63,22 +64,28 @@
</div>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/assets/templates/{{.AssetTemplate.Name}}/graph">{{icon "assets"}}<span>{{if .AssetTemplate.ReadOnly}}可视化预览{{else}}可视化编辑{{end}}</span></a>
<a class="btn secondary" href="/ui/assets/templates/{{.AssetTemplate.Name}}/graph{{if .TemplateCloneSource}}?clone_source={{.TemplateCloneSource}}{{end}}">{{icon "assets"}}<span>{{if .AssetTemplate.ReadOnly}}可视化预览{{else if .TemplateCloneSource}}进入可视化编辑{{else}}可视化编辑{{end}}</span></a>
{{if not .TemplateCloneSource}}
<button
type="button"
class="btn secondary js-export-json"
data-export-url="/ui/assets/templates/{{.AssetTemplate.Name}}/export"
data-default-filename="{{.AssetTemplate.Name}}.json"
>{{icon "apply"}}<span>导出为 JSON</span></button>
{{end}}
</div>
</div>
{{if .AssetTemplateEditing}}
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/rename">
<form method="post" action="{{if .TemplateCloneSource}}/ui/assets/templates/create{{else}}/ui/assets/templates/{{.AssetTemplate.Name}}/rename{{end}}">
<div class="field-grid">
<label><span>模板名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetTemplate.Name}}" class="mono" autofocus /></label>
<label><span>模板类型</span><input value="{{if .AssetTemplate.ReadOnly}}标准模板(只读){{else}}用户模板{{end}}" readonly /></label>
<label class="full"><span>模板描述</span><input name="description" value="{{.AssetTemplate.Description}}" /></label>
</div>
{{if .TemplateCloneSource}}
<input type="hidden" name="clone_source" value="{{.TemplateCloneSource}}" />
{{end}}
{{if not .TemplateCloneSource}}
<div class="detail-sheet">
<div class="detail-item"><span>来源文件</span><strong class="mono">{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>节点数</span><strong>{{.AssetTemplate.NodeCount}}</strong></div>
@ -88,9 +95,10 @@
<div class="detail-item"><span>输出槽位</span><strong>{{if .AssetTemplate.Slots.Outputs}}{{len .AssetTemplate.Slots.Outputs}}{{else}}0{{end}}</strong></div>
<div class="detail-item full"><span>路径</span><strong class="mono">{{.AssetTemplate.Path}}</strong></div>
</div>
{{end}}
<div class="actions">
<button type="submit" class="btn secondary">{{icon "apply"}}<span>保存</span></button>
<a class="btn secondary" href="/ui/assets/templates?name={{.AssetTemplate.Name}}">{{icon "close"}}<span>取消</span></a>
<button type="submit" class="btn secondary">{{icon "apply"}}<span>{{if .TemplateCloneSource}}创建模板{{else}}保存{{end}}</span></button>
<a class="btn secondary" href="/ui/assets/templates">{{icon "close"}}<span>取消</span></a>
</div>
</form>
{{else}}
@ -115,7 +123,7 @@
{{end}}
</div>
{{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)}}
<div class="card">
<div class="section-title">
<div>
@ -136,13 +144,13 @@
</div>
{{end}}
{{if .AssetTemplate.ReadOnly}}
{{if and (not .TemplateCloneSource) .AssetTemplate.ReadOnly}}
<div class="card">
<div class="form-hint">标准模板保留在模板目录中,作为只读基线使用。需要调整流程时,请先复制为用户模板,再进入可视化编辑保存。</div>
</div>
{{end}}
{{if .AssetTemplate.AdvancedParams}}
{{if and (not .TemplateCloneSource) .AssetTemplate.AdvancedParams}}
<details class="card collapsible">
<summary class="title-with-icon">{{icon "tech"}}<span>高级设置</span></summary>
<pre>{{json .AssetTemplate.AdvancedParams}}</pre>

View File

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

View File

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

View File

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

View File

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