diff --git a/internal/service/config_assets.go b/internal/service/config_assets.go index 136122c..d7045eb 100644 --- a/internal/service/config_assets.go +++ b/internal/service/config_assets.go @@ -11,9 +11,16 @@ import ( "3588AdminBackend/internal/storage" ) +var legacyBuiltinTemplateAliases = map[string]string{ + "workshop_face_shoe_alarm": "std_workshop_face_recognition_shoe_alarm", + "std_service_smoke_stream": "std_service_test_stream", +} + type ConfigTemplateAsset struct { Name string `json:"name"` Path string `json:"path"` + Origin string `json:"origin"` + ReadOnly bool `json:"read_only"` Description string `json:"description"` Source string `json:"source"` NodeCount int `json:"node_count"` @@ -62,58 +69,130 @@ type ConfigOverlayAsset struct { } func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, error) { - sources, err := s.ListSources() - if err != nil { - return nil, err - } - items := make([]ConfigTemplateAsset, 0, len(sources.Templates)) - for _, source := range sources.Templates { - item, err := s.GetTemplateAsset(source.Name) + items := make([]ConfigTemplateAsset, 0) + seen := map[string]bool{} + + root := s.mediaRepoRoot() + if root != "" { + sources, err := listConfigSources(filepath.Join(root, "configs", "templates")) if err != nil { - continue + if s.hasExplicitRoot() { + return nil, err + } + } else { + for _, source := range sources { + item, err := s.templateAssetFromPath(source.Name, source.Path, "builtin", true) + if err != nil { + continue + } + items = append(items, *item) + seen[item.Name] = true + } } - items = append(items, *item) } + + if s != nil && s.assets != nil { + records, err := s.assets.ListTemplates() + if err != nil { + return nil, err + } + for _, record := range records { + name := strings.TrimSpace(record.Name) + if name == "" || seen[name] || legacyBuiltinTemplateAliases[name] != "" { + continue + } + item, err := s.templateAssetFromRecord(record, "user", false) + if err != nil { + continue + } + items = append(items, *item) + seen[item.Name] = true + } + } + + 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 } func (s *ConfigPreviewService) GetTemplateAsset(name string) (*ConfigTemplateAsset, error) { - raw, path, err := s.readAssetJSON("templates", name) - if err != nil { + name = canonicalTemplateAssetName(name) + if err := validateConfigName(name); err != nil { return nil, err } - templateMap, _ := raw["template"].(map[string]any) - paramsMap, _ := raw["params"].(map[string]any) - nodes, _ := templateMap["nodes"].([]any) - edges, _ := templateMap["edges"].([]any) - advanced := cloneMap(paramsMap) - for _, key := range []string{ - "minio_endpoint", - "minio_bucket", - "external_get_token_url", - "external_put_message_url", - "tenant_code", - } { - delete(advanced, key) + if path, ok := s.mediaAssetPath("templates", name); ok { + return s.templateAssetFromPath(name, path, "builtin", true) } - if len(advanced) == 0 { - advanced = nil + if s != nil && s.assets != nil { + record, err := s.assets.GetTemplate(name) + if err != nil { + return nil, err + } + if record != nil { + return s.templateAssetFromRecord(*record, "user", false) + } } - return &ConfigTemplateAsset{ - Name: firstString(raw["name"], name), - Path: path, - Description: stringValue(raw["description"]), - Source: stringValue(raw["source"]), - NodeCount: len(nodes), - EdgeCount: len(edges), - MinIOEndpoint: stringValue(paramsMap["minio_endpoint"]), - MinIOBucket: stringValue(paramsMap["minio_bucket"]), - ExternalGetTokenURL: stringValue(paramsMap["external_get_token_url"]), - ExternalPutMessageURL: stringValue(paramsMap["external_put_message_url"]), - TenantCode: valueString(paramsMap["tenant_code"]), - AdvancedParams: advanced, - Raw: raw, - }, nil + return nil, os.ErrNotExist +} + +func (s *ConfigPreviewService) SaveTemplateAsset(name string, description string, bodyJSON string) error { + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + name = canonicalTemplateAssetName(name) + if err := validateConfigName(name); err != nil { + return err + } + if s.templateIsBuiltin(name) { + return fmt.Errorf("standard template %q is read-only; please copy it before editing", name) + } + return s.assets.SaveTemplate(name, description, bodyJSON) +} + +func (s *ConfigPreviewService) RenameTemplateAsset(oldName string, newName string, description string, bodyJSON string) error { + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + oldName = canonicalTemplateAssetName(oldName) + newName = canonicalTemplateAssetName(newName) + if err := validateConfigName(oldName); err != nil { + return err + } + if err := validateConfigName(newName); err != nil { + return err + } + if s.templateIsBuiltin(oldName) { + return fmt.Errorf("standard template %q is read-only; please copy it before editing", oldName) + } + if oldName != newName && s.templateIsBuiltin(newName) { + return fmt.Errorf("standard template name %q is reserved", newName) + } + return s.assets.RenameTemplate(oldName, newName, description, bodyJSON) +} + +func (s *ConfigPreviewService) DeleteTemplateAsset(name string) error { + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + name = canonicalTemplateAssetName(name) + if err := validateConfigName(name); err != nil { + return err + } + if s.templateIsBuiltin(name) { + return fmt.Errorf("standard template %q is read-only and cannot be deleted", name) + } + refs, err := s.profileNamesReferencingTemplate(name) + if err != nil { + return err + } + if len(refs) > 0 { + return fmt.Errorf("template %q is used by business configs: %s", name, strings.Join(refs, ", ")) + } + return s.assets.DeleteTemplate(name) } func (s *ConfigPreviewService) ListProfileAssets() ([]ConfigProfileAsset, error) { @@ -132,6 +211,24 @@ func (s *ConfigPreviewService) ListProfileAssets() ([]ConfigProfileAsset, error) return items, nil } +func (s *ConfigPreviewService) profileNamesReferencingTemplate(templateName string) ([]string, error) { + if s == nil || s.assets == nil { + return nil, nil + } + records, err := s.assets.ListProfiles() + if err != nil { + return nil, err + } + refs := make([]string, 0) + for _, record := range records { + if strings.TrimSpace(record.TemplateName) == templateName || strings.Contains(record.BodyJSON, `"`+"template"+`": "`+templateName+`"`) { + refs = append(refs, record.Name) + } + } + sort.Strings(refs) + return refs, nil +} + func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset, error) { raw, path, err := s.readAssetJSON("profiles", name) if err != nil { @@ -308,6 +405,95 @@ func cloneMap(in map[string]any) map[string]any { return out } +func (s *ConfigPreviewService) templateAssetFromPath(name string, path string, origin string, readOnly bool) (*ConfigTemplateAsset, error) { + body, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + return nil, err + } + return buildTemplateAsset(raw, path, origin, readOnly), nil +} + +func (s *ConfigPreviewService) templateAssetFromRecord(record storage.AssetRecord, origin string, readOnly bool) (*ConfigTemplateAsset, error) { + var raw map[string]any + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + return nil, err + } + if raw == nil { + raw = map[string]any{} + } + if strings.TrimSpace(record.Description) != "" { + raw["description"] = record.Description + } + return buildTemplateAsset(raw, repoAssetPath("templates", record.Name), origin, readOnly), nil +} + +func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly bool) *ConfigTemplateAsset { + templateMap, _ := raw["template"].(map[string]any) + paramsMap, _ := raw["params"].(map[string]any) + nodes, _ := templateMap["nodes"].([]any) + edges, _ := templateMap["edges"].([]any) + advanced := cloneMap(paramsMap) + for _, key := range []string{ + "minio_endpoint", + "minio_bucket", + "external_get_token_url", + "external_put_message_url", + "tenant_code", + } { + delete(advanced, key) + } + if len(advanced) == 0 { + advanced = nil + } + return &ConfigTemplateAsset{ + Name: firstString(raw["name"], filepath.Base(strings.TrimSuffix(path, filepath.Ext(path)))), + Path: path, + Origin: origin, + ReadOnly: readOnly, + Description: stringValue(raw["description"]), + Source: stringValue(raw["source"]), + NodeCount: len(nodes), + EdgeCount: len(edges), + MinIOEndpoint: stringValue(paramsMap["minio_endpoint"]), + MinIOBucket: stringValue(paramsMap["minio_bucket"]), + ExternalGetTokenURL: stringValue(paramsMap["external_get_token_url"]), + ExternalPutMessageURL: stringValue(paramsMap["external_put_message_url"]), + TenantCode: valueString(paramsMap["tenant_code"]), + AdvancedParams: advanced, + Raw: raw, + } +} + +func (s *ConfigPreviewService) mediaAssetPath(kind string, name string) (string, bool) { + root := s.mediaRepoRoot() + if root == "" { + return "", false + } + path := filepath.Join(root, "configs", kind, name+".json") + if _, err := os.Stat(path); err != nil { + return "", false + } + return path, true +} + +func (s *ConfigPreviewService) templateIsBuiltin(name string) bool { + name = canonicalTemplateAssetName(name) + _, ok := s.mediaAssetPath("templates", name) + return ok +} + +func canonicalTemplateAssetName(name string) string { + name = strings.TrimSpace(name) + if next := strings.TrimSpace(legacyBuiltinTemplateAliases[name]); next != "" { + return next + } + return name +} + func stringValue(v any) string { if s, ok := v.(string); ok { return strings.TrimSpace(s) diff --git a/internal/service/config_assets_test.go b/internal/service/config_assets_test.go index 6a7ab60..1a828ea 100644 --- a/internal/service/config_assets_test.go +++ b/internal/service/config_assets_test.go @@ -3,6 +3,7 @@ package service import ( "encoding/json" "path/filepath" + "strings" "testing" "3588AdminBackend/internal/config" @@ -11,8 +12,8 @@ import ( func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) { root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{ - "name": "workshop_face_shoe_alarm", + mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{ + "name": "std_workshop_face_recognition_shoe_alarm", "params": { "minio_endpoint": "http://10.0.0.49:9000", "minio_bucket": "myminio", @@ -30,7 +31,7 @@ func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) { "queue": {"size": 8, "strategy": "drop_oldest"}, "instances": [{ "name": "cam1", - "template": "workshop_face_shoe_alarm", + "template": "std_workshop_face_recognition_shoe_alarm", "params": { "display_name": "东门入口", "device_code": "rk3588-a-001", @@ -65,7 +66,7 @@ func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) { if _, ok := inst.AdvancedParams["queue_debug"]; !ok { t.Fatalf("expected advanced params to preserve extra keys, got %#v", inst.AdvancedParams) } - if item, err := svc.GetTemplateAsset("workshop_face_shoe_alarm"); err != nil { + if item, err := svc.GetTemplateAsset("std_workshop_face_recognition_shoe_alarm"); err != nil { t.Fatalf("GetTemplateAsset: %v", err) } else { if item.MinIOEndpoint != "http://10.0.0.49:9000" || item.TenantCode != "32" { @@ -79,7 +80,7 @@ func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) { func TestConfigPreviewServiceGetsOverlayAssetTargets(t *testing.T) { root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{}`) + mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{}`) mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`) mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{ "description": "debug overlay", @@ -113,7 +114,7 @@ func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) { "instances": [ { "name": "cam1", - "template": "workshop_face_shoe_alarm", + "template": "std_workshop_face_recognition_shoe_alarm", "params": { "display_name": "东门入口", "device_code": "rk3588-a-001", @@ -128,7 +129,7 @@ func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) { }, { "name": "cam2", - "template": "workshop_face_shoe_alarm", + "template": "std_workshop_face_recognition_shoe_alarm", "params": { "display_name": "西门入口", "rtsp_url": "rtsp://10.0.0.2/live", @@ -194,7 +195,7 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) { Instances: []ConfigProfileInstanceEditor{ { Name: "cam1", - Template: "workshop_face_shoe_alarm", + Template: "std_workshop_face_recognition_shoe_alarm", DisplayName: "东门入口", RTSPURL: "rtsp://10.0.0.1/live", PublishHLSPath: "./web/hls/cam1/index.m3u8", @@ -207,7 +208,7 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) { }, { Name: "cam2", - Template: "workshop_face_shoe_alarm", + Template: "std_workshop_face_recognition_shoe_alarm", DisplayName: "视觉识别终端-B厂区", RTSPURL: "rtsp://10.0.0.2/live", PublishHLSPath: "./web/hls/cam2/index.m3u8", @@ -380,3 +381,143 @@ func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(t *testing.T) { t.Fatalf("expected description, got %#v", saved) } } + +func TestConfigPreviewServicePrefersBuiltinTemplateOverRepoShadow(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{ + "name": "helmet", + "description": "builtin template", + "template": {"nodes": [{"id":"input_rtsp_main"}], "edges": []} +}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "shadow template", `{"name":"helmet","description":"shadow template","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + item, err := svc.GetTemplateAsset("helmet") + if err != nil { + t.Fatalf("GetTemplateAsset: %v", err) + } + if !item.ReadOnly || item.Origin != "builtin" { + t.Fatalf("expected builtin readonly template, got %#v", item) + } + if item.Description != "builtin template" { + t.Fatalf("expected builtin template payload, got %#v", item) + } + items, err := svc.ListTemplateAssets() + if err != nil { + t.Fatalf("ListTemplateAssets: %v", err) + } + if len(items) != 1 || items[0].Name != "helmet" || !items[0].ReadOnly { + t.Fatalf("expected only builtin template in merged list, got %#v", items) + } +} + +func TestConfigPreviewServiceRejectsSavingBuiltinTemplateName(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","template":{"nodes":[],"edges":[]}}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + + err = svc.SaveTemplateAsset("helmet", "new body", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`) + if err == nil || !strings.Contains(err.Error(), "read-only") { + t.Fatalf("expected readonly rejection, got %v", err) + } +} + +func TestConfigPreviewServiceRenamesTemplateAndUpdatesProfileRefs(t *testing.T) { + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + svc := NewConfigPreviewService(&config.Config{}, repo) + + err = svc.RenameTemplateAsset("helmet", "helmet_v2", "helmet v2", `{"name":"helmet_v2","description":"helmet v2","template":{"nodes":[],"edges":[]}}`) + if err != nil { + t.Fatalf("RenameTemplateAsset: %v", err) + } + + record, err := repo.GetTemplate("helmet_v2") + if err != nil { + t.Fatalf("GetTemplate: %v", err) + } + if record == nil || !strings.Contains(record.BodyJSON, `"name":"helmet_v2"`) { + t.Fatalf("expected renamed template, got %#v", record) + } + profile, err := repo.GetProfile("gate_a") + if err != nil { + t.Fatalf("GetProfile: %v", err) + } + if profile == nil || profile.TemplateName != "helmet_v2" || (!strings.Contains(profile.BodyJSON, `"template": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"template":"helmet_v2"`)) { + t.Fatalf("expected updated profile refs, got %#v", profile) + } +} + +func TestConfigPreviewServiceRejectsDeletingReferencedTemplate(t *testing.T) { + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + svc := NewConfigPreviewService(&config.Config{}, repo) + + err = svc.DeleteTemplateAsset("helmet") + if err == nil || !strings.Contains(err.Error(), "used by business configs") { + t.Fatalf("expected reference rejection, got %v", err) + } +} + +func TestConfigPreviewServiceImportAssetsSkipsBuiltinTemplates(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","template":{"nodes":[],"edges":[]}}`) + mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`) + mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`) + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + defer store.Close() + repo := storage.NewAssetsRepo(store.DB()) + svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo) + + result, err := svc.ImportAssetsFromMediaRepo() + if err != nil { + t.Fatalf("ImportAssetsFromMediaRepo: %v", err) + } + if result.Templates != 0 || result.Profiles != 1 || result.Overlays != 1 { + t.Fatalf("unexpected import result: %#v", result) + } + record, err := repo.GetTemplate("helmet") + if err != nil { + t.Fatalf("GetTemplate: %v", err) + } + if record != nil { + t.Fatalf("expected builtin template to stay out of sqlite, got %#v", record) + } +} diff --git a/internal/service/config_preview.go b/internal/service/config_preview.go index 87f7f0a..4f8241a 100644 --- a/internal/service/config_preview.go +++ b/internal/service/config_preview.go @@ -70,36 +70,79 @@ func NewConfigPreviewService(cfg *config.Config, repo ...*storage.AssetsRepo) *C } func (s *ConfigPreviewService) ListSources() (ConfigPreviewSources, error) { - if out, ok, err := s.listRepoSources(); ok || err != nil { - return out, err - } root := s.mediaRepoRoot() - if root == "" { - return defaultConfigPreviewSources(""), nil + out := ConfigPreviewSources{Root: root} + seenTemplates := map[string]bool{} + if root != "" { + templates, err := listConfigSources(filepath.Join(root, "configs", "templates")) + if err != nil { + if s.hasExplicitRoot() { + return out, err + } + } else { + out.Templates = append(out.Templates, templates...) + for _, item := range templates { + seenTemplates[item.Name] = true + } + } + profiles, err := listConfigSources(filepath.Join(root, "configs", "profiles")) + if err != nil { + if s.hasExplicitRoot() { + return out, err + } + } else { + out.Profiles = profiles + } + overlays, err := listConfigSources(filepath.Join(root, "configs", "overlays")) + if err != nil { + if s.hasExplicitRoot() { + return out, err + } + } else { + out.Overlays = overlays + } } - out := ConfigPreviewSources{Root: root} - var err error - out.Templates, err = listConfigSources(filepath.Join(root, "configs", "templates")) - if err != nil { - if s.hasExplicitRoot() { + if s != nil && s.assets != nil { + templates, err := s.assets.ListTemplates() + if err != nil { return out, err } - return defaultConfigPreviewSources(root), nil + for _, item := range templates { + if seenTemplates[item.Name] { + continue + } + out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)}) + } + profiles, err := s.assets.ListProfiles() + if err != nil { + return out, err + } + if len(profiles) > 0 { + out.Profiles = out.Profiles[:0] + for _, item := range profiles { + out.Profiles = append(out.Profiles, ConfigSource{Name: item.Name, Path: repoAssetPath("profiles", item.Name)}) + } + } + overlays, err := s.assets.ListOverlays() + if err != nil { + return out, err + } + if len(overlays) > 0 { + out.Overlays = out.Overlays[:0] + for _, item := range overlays { + out.Overlays = append(out.Overlays, ConfigSource{Name: item.Name, Path: repoAssetPath("overlays", item.Name)}) + } + } } - out.Profiles, err = listConfigSources(filepath.Join(root, "configs", "profiles")) - if err != nil { - if s.hasExplicitRoot() { - return out, err - } - return defaultConfigPreviewSources(root), nil + sort.Slice(out.Templates, func(i, j int) bool { return out.Templates[i].Name < out.Templates[j].Name }) + sort.Slice(out.Profiles, func(i, j int) bool { return out.Profiles[i].Name < out.Profiles[j].Name }) + sort.Slice(out.Overlays, func(i, j int) bool { return out.Overlays[i].Name < out.Overlays[j].Name }) + if out.Root == "" && s != nil && s.assets != nil && (len(out.Templates) > 0 || len(out.Profiles) > 0 || len(out.Overlays) > 0) { + out.Root = "SQLite" } - out.Overlays, err = listConfigSources(filepath.Join(root, "configs", "overlays")) - if err != nil { - if s.hasExplicitRoot() { - return out, err - } - return defaultConfigPreviewSources(root), nil + if out.Root == "" && len(out.Templates) == 0 && len(out.Profiles) == 0 && len(out.Overlays) == 0 { + return defaultConfigPreviewSources(""), nil } return out, nil } @@ -318,7 +361,10 @@ func defaultConfigPreviewSources(root string) ConfigPreviewSources { return ConfigPreviewSources{ Root: root, Templates: []ConfigSource{ - {Name: "workshop_face_shoe_alarm"}, + {Name: "std_face_recognition_stream"}, + {Name: "std_service_test_stream"}, + {Name: "std_workshoe_detection_stream"}, + {Name: "std_workshop_face_recognition_shoe_alarm"}, }, Profiles: []ConfigSource{ {Name: "local_3588_test"}, @@ -350,7 +396,6 @@ func (s *ConfigPreviewService) ImportAssetsFromMediaRepo() (*ConfigAssetImportRe kind string inc *int }{ - {kind: "templates", inc: &result.Templates}, {kind: "profiles", inc: &result.Profiles}, {kind: "overlays", inc: &result.Overlays}, } { diff --git a/internal/service/config_preview_test.go b/internal/service/config_preview_test.go index f6f2a09..cd74117 100644 --- a/internal/service/config_preview_test.go +++ b/internal/service/config_preview_test.go @@ -12,7 +12,7 @@ import ( func TestConfigPreviewServiceListsSources(t *testing.T) { root := t.TempDir() - mustWrite(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{}`) + mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{}`) mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`) mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`) @@ -25,7 +25,7 @@ func TestConfigPreviewServiceListsSources(t *testing.T) { if sources.Root != root { t.Fatalf("expected root %q, got %q", root, sources.Root) } - if got := sourceNames(sources.Templates); strings.Join(got, ",") != "workshop_face_shoe_alarm" { + if got := sourceNames(sources.Templates); strings.Join(got, ",") != "std_workshop_face_recognition_shoe_alarm" { t.Fatalf("unexpected templates: %v", got) } if got := sourceNames(sources.Profiles); strings.Join(got, ",") != "local_3588_test" { @@ -69,7 +69,7 @@ func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) { if err != nil { t.Fatalf("ImportAssetsFromMediaRepo: %v", err) } - if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 { + if result.Templates != 0 || result.Profiles != 1 || result.Overlays != 1 { t.Fatalf("unexpected import result: %#v", result) } @@ -80,6 +80,11 @@ func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) { if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" { t.Fatalf("unexpected templates after import: %#v", got) } + if record, err := repo.GetTemplate("helmet"); err != nil { + t.Fatalf("GetTemplate: %v", err) + } else if record != nil { + t.Fatalf("expected builtin template to remain outside sqlite, got %#v", record) + } } func TestConfigPreviewServiceExportsAssetJSONFromSQLite(t *testing.T) { diff --git a/internal/storage/assets_repo.go b/internal/storage/assets_repo.go index cbba370..677a5e7 100644 --- a/internal/storage/assets_repo.go +++ b/internal/storage/assets_repo.go @@ -2,6 +2,9 @@ package storage import ( "database/sql" + "encoding/json" + "fmt" + "strings" "time" ) @@ -73,6 +76,111 @@ func (r *AssetsRepo) GetOverlay(name string) (*AssetRecord, error) { return r.getAsset("overlays", name) } +func (r *AssetsRepo) DeleteTemplate(name string) error { + return r.deleteAsset("templates", name) +} + +func (r *AssetsRepo) RenameTemplate(oldName string, newName string, description string, bodyJSON string) error { + if r == nil || r.db == nil { + return nil + } + oldName = strings.TrimSpace(oldName) + newName = strings.TrimSpace(newName) + if oldName == "" || newName == "" { + return fmt.Errorf("template name is required") + } + if oldName == newName { + return r.SaveTemplate(newName, description, bodyJSON) + } + + tx, err := r.db.Begin() + if err != nil { + return err + } + defer func() { + if tx != nil { + _ = tx.Rollback() + } + }() + + var exists int + if err := tx.QueryRow(`SELECT COUNT(1) FROM templates WHERE name = ?`, oldName).Scan(&exists); err != nil { + return err + } + if exists == 0 { + return sql.ErrNoRows + } + if err := tx.QueryRow(`SELECT COUNT(1) FROM templates WHERE name = ?`, newName).Scan(&exists); err != nil { + return err + } + if exists > 0 { + return fmt.Errorf("template %q already exists", newName) + } + + now := time.Now().Format(time.RFC3339) + if _, err := tx.Exec(` +INSERT INTO templates(name, description, body_json, created_at, updated_at) +SELECT ?, ?, ?, created_at, ? +FROM templates +WHERE name = ? +`, newName, description, bodyJSON, now, oldName); err != nil { + return err + } + + rows, err := tx.Query(` +SELECT name, description, business_name, body_json +FROM profiles +WHERE template_name = ? OR body_json LIKE ? +`, oldName, "%\"template\":\""+oldName+"\"%") + if err != nil { + return err + } + defer rows.Close() + + type profileUpdate struct { + name string + description string + businessName string + bodyJSON string + } + updates := make([]profileUpdate, 0) + for rows.Next() { + var item profileUpdate + if err := rows.Scan(&item.name, &item.description, &item.businessName, &item.bodyJSON); err != nil { + return err + } + rewritten, changed, err := rewriteProfileTemplateRefs(item.bodyJSON, oldName, newName) + if err != nil { + return err + } + if changed { + item.bodyJSON = rewritten + } + updates = append(updates, item) + } + if err := rows.Err(); err != nil { + return err + } + for _, item := range updates { + if _, err := tx.Exec(` +UPDATE profiles +SET template_name = ?, body_json = ?, updated_at = ? +WHERE name = ? +`, newName, item.bodyJSON, now, item.name); err != nil { + return err + } + } + + if _, err := tx.Exec(`DELETE FROM templates WHERE name = ?`, oldName); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + tx = nil + return nil +} + func (r *AssetsRepo) saveAsset(table string, record AssetRecord) error { if r == nil || r.db == nil { return nil @@ -166,3 +274,46 @@ WHERE name = ? } return &item, nil } + +func (r *AssetsRepo) deleteAsset(table string, name string) error { + if r == nil || r.db == nil { + return nil + } + _, err := r.db.Exec(`DELETE FROM `+table+` WHERE name = ?`, name) + return err +} + +func rewriteProfileTemplateRefs(bodyJSON string, oldName string, newName string) (string, bool, error) { + var raw map[string]any + if err := json.Unmarshal([]byte(bodyJSON), &raw); err != nil { + return "", false, err + } + changed := false + instances, _ := raw["instances"].([]any) + for _, item := range instances { + instanceMap, _ := item.(map[string]any) + if strings.TrimSpace(valueString(instanceMap["template"])) == oldName { + instanceMap["template"] = newName + changed = true + } + } + if !changed { + return bodyJSON, false, nil + } + body, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return "", false, err + } + return string(append(body, '\n')), true, nil +} + +func valueString(v any) string { + switch vv := v.(type) { + case string: + return vv + case float64: + return fmt.Sprintf("%v", vv) + default: + return "" + } +} diff --git a/internal/storage/assets_repo_test.go b/internal/storage/assets_repo_test.go index e1abe3c..1eb4db8 100644 --- a/internal/storage/assets_repo_test.go +++ b/internal/storage/assets_repo_test.go @@ -1,6 +1,9 @@ package storage -import "testing" +import ( + "strings" + "testing" +) func TestAssetsRepoStoresTemplateProfileAndOverlayJSON(t *testing.T) { store := openTestStore(t) @@ -39,3 +42,63 @@ func TestAssetsRepoStoresTemplateProfileAndOverlayJSON(t *testing.T) { t.Fatalf("unexpected overlays: %#v", overlays) } } + +func TestAssetsRepoRenameTemplateUpdatesProfileReferences(t *testing.T) { + store := openTestStore(t) + defer store.Close() + + repo := NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + + err := repo.RenameTemplate("helmet", "helmet_v2", "helmet v2", `{"name":"helmet_v2","description":"helmet v2","template":{"nodes":[],"edges":[]}}`) + if err != nil { + t.Fatalf("RenameTemplate: %v", err) + } + + oldRecord, err := repo.GetTemplate("helmet") + if err != nil { + t.Fatalf("GetTemplate old: %v", err) + } + if oldRecord != nil { + t.Fatalf("expected old template to be removed, got %#v", oldRecord) + } + newRecord, err := repo.GetTemplate("helmet_v2") + if err != nil { + t.Fatalf("GetTemplate new: %v", err) + } + if newRecord == nil || newRecord.Description != "helmet v2" || !strings.Contains(newRecord.BodyJSON, `"name":"helmet_v2"`) { + t.Fatalf("expected renamed template, got %#v", newRecord) + } + profile, err := repo.GetProfile("gate_a") + if err != nil { + t.Fatalf("GetProfile: %v", err) + } + if profile == nil || profile.TemplateName != "helmet_v2" || !strings.Contains(profile.BodyJSON, `"template": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"template":"helmet_v2"`) { + t.Fatalf("expected profile template ref updated, got %#v", profile) + } +} + +func TestAssetsRepoDeleteTemplateRemovesRecord(t *testing.T) { + store := openTestStore(t) + defer store.Close() + + repo := NewAssetsRepo(store.DB()) + if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + if err := repo.DeleteTemplate("helmet"); err != nil { + t.Fatalf("DeleteTemplate: %v", err) + } + record, err := repo.GetTemplate("helmet") + if err != nil { + t.Fatalf("GetTemplate: %v", err) + } + if record != nil { + t.Fatalf("expected template deleted, got %#v", record) + } +} diff --git a/internal/web/graph_node_types.go b/internal/web/graph_node_types.go new file mode 100644 index 0000000..94fd14a --- /dev/null +++ b/internal/web/graph_node_types.go @@ -0,0 +1,159 @@ +package web + +type graphNodeParam struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` + Step string `json:"step,omitempty"` + Placeholder string `json:"placeholder,omitempty"` + Options []string `json:"options,omitempty"` +} + +type graphNodeTypeInfo struct { + Type string `json:"type"` + Label string `json:"label"` + Category string `json:"category"` + Icon string `json:"icon"` + Description string `json:"description"` + Defaults map[string]any `json:"defaults"` + Params []graphNodeParam `json:"params,omitempty"` +} + +func graphNodeTypesCatalog() []graphNodeTypeInfo { + return []graphNodeTypeInfo{ + nodeType("input_rtsp", "RTSP 输入", "输入", "camera", "从网络摄像机或流媒体地址读取视频流。", "source", map[string]any{"url": "${rtsp_url}"}, []graphNodeParam{ + textParam("url", "RTSP 地址", "${rtsp_url}"), + numberParam("fps", "输入帧率", "1"), + numberParam("width", "宽度", "1"), + numberParam("height", "高度", "1"), + boolParam("force_tcp", "强制 TCP"), + numberParam("reconnect_sec", "重连间隔秒", "1"), + }), + nodeType("input_file", "文件输入", "输入", "file", "从本地视频文件读取帧,常用于离线验证和回放。", "source", nil, []graphNodeParam{ + textParam("path", "文件路径", ""), + numberParam("fps", "回放帧率", "1"), + boolParam("loop", "循环播放"), + }), + nodeType("preprocess", "图像预处理", "处理", "adjust", "调整尺寸、格式和硬件加速路径,为后续推理或编码准备图像。", "filter", map[string]any{"dst_format": "rgb"}, []graphNodeParam{ + numberParam("dst_w", "输出宽度", "1"), + numberParam("dst_h", "输出高度", "1"), + selectParam("dst_format", "输出格式", []string{"rgb", "nv12", "bgr"}), + selectParam("resize_mode", "缩放方式", []string{"stretch", "letterbox"}), + boolParam("use_rga", "使用 RGA"), + textParam("rga_gate", "RGA 通道", ""), + }), + nodeType("ai_scrfd", "SCRFD 人脸检测", "AI 推理", "scan-face", "使用 SCRFD 模型做人脸检测,适合固定输入尺寸场景。", "filter", nil, faceDetParams()), + nodeType("ai_scrfd_sliding", "滑窗人脸检测", "AI 推理", "scan-face", "使用滑窗方式执行 SCRFD 人脸检测,适合高分辨率画面。", "filter", nil, faceDetParams()), + nodeType("ai_face_det", "人脸检测", "AI 推理", "face", "通用人脸检测节点,输出人脸框和质量信息。", "filter", nil, faceDetParams()), + nodeType("ai_face_recog", "人脸识别", "AI 推理", "face-id", "对检测到的人脸进行特征提取和人脸库匹配。", "filter", nil, []graphNodeParam{ + textParam("model_path", "模型路径", ""), + numberParam("infer_fps", "推理帧率", "0.1"), + boolParam("align", "人脸对齐"), + boolParam("emit_embedding", "输出特征"), + numberParam("max_faces", "最大人脸数", "1"), + }), + nodeType("ai_yolo", "YOLO 目标检测", "AI 推理", "target", "使用 YOLO 模型检测人员、PPE 或其他目标。", "filter", nil, []graphNodeParam{ + textParam("model_path", "模型路径", ""), + numberParam("infer_fps", "推理帧率", "0.1"), + numberParam("model_w", "模型宽度", "1"), + numberParam("model_h", "模型高度", "1"), + numberParam("conf", "置信度", "0.01"), + numberParam("nms", "NMS", "0.01"), + }), + nodeType("ai_shoe_det", "鞋靴检测", "AI 推理", "shoe", "检测鞋靴和工鞋相关目标,可配合逻辑节点判断违规。", "filter", nil, []graphNodeParam{ + textParam("model_path", "模型路径", ""), + numberParam("infer_fps", "推理帧率", "0.1"), + numberParam("conf", "置信度", "0.01"), + numberParam("nms", "NMS", "0.01"), + boolParam("append_detections", "追加检测结果"), + }), + nodeType("tracker", "目标跟踪", "处理", "route", "对检测目标分配跟踪 ID,保持跨帧目标状态。", "filter", nil, []graphNodeParam{ + textParam("mode", "跟踪模式", ""), + boolParam("per_class", "按类别跟踪"), + numberParam("high_th", "高阈值", "0.01"), + numberParam("low_th", "低阈值", "0.01"), + numberParam("iou_th", "IOU 阈值", "0.01"), + numberParam("max_age_ms", "最大保留毫秒", "1"), + }), + nodeType("logic_gate", "规则判断", "规则", "branch", "根据检测、跟踪或颜色分析结果进行业务规则判断。", "filter", nil, []graphNodeParam{ + textParam("mode", "逻辑模式", ""), + boolParam("debug", "调试输出"), + numberParam("anchor_class", "锚点类别", "1"), + numberParam("boots_class", "鞋靴类别", "1"), + numberParam("violation_class", "违规类别", "1"), + }), + nodeType("event_fusion", "事件融合", "规则", "merge", "融合多路事件,减少重复告警并形成更稳定的业务事件。", "filter", nil, nil), + nodeType("region_event", "区域事件", "规则", "region", "基于区域、越线或停留规则生成区域行为事件。", "filter", nil, nil), + nodeType("action_recog", "行为识别", "AI 推理", "activity", "识别人员行为或动作事件。", "filter", nil, nil), + nodeType("det_post", "检测后处理", "处理", "filter", "对检测结果做过滤、映射、合并或类别转换。", "filter", nil, nil), + nodeType("osd", "画面叠加", "输出", "overlay", "在视频帧上绘制检测框、文字、人脸识别和事件信息。", "filter", nil, []graphNodeParam{ + boolParam("draw_bbox", "绘制框"), + boolParam("draw_text", "绘制文字"), + boolParam("draw_face_det", "绘制人脸检测"), + boolParam("draw_face_recog", "绘制人脸识别"), + numberParam("line_width", "线宽", "0.1"), + numberParam("font_scale", "字体缩放", "0.1"), + }), + nodeType("publish", "视频输出", "输出", "broadcast", "编码并发布 RTSP、HLS 或其他视频输出。", "sink", nil, []graphNodeParam{ + selectParam("codec", "编码", []string{"h264", "h265"}), + numberParam("fps", "输出帧率", "1"), + numberParam("gop", "GOP", "1"), + numberParam("bitrate_kbps", "码率 kbps", "1"), + boolParam("use_mpp", "使用 MPP"), + boolParam("use_ffmpeg_mux", "FFmpeg 封装"), + }), + nodeType("storage", "本地存储", "输出", "database", "保存帧、事件或中间结果到本地存储。", "sink", nil, nil), + nodeType("alarm", "告警动作", "输出", "bell", "根据规则触发日志、抓图、录像片段、外部接口等动作。", "sink", nil, []graphNodeParam{ + numberParam("eval_fps", "评估帧率", "0.1"), + }), + nodeType("gate", "流控闸门", "系统", "gate", "控制流程分支或限流,保护下游节点。", "filter", nil, nil), + nodeType("zlm_http", "ZLMediaKit HTTP", "系统", "server", "提供 ZLMediaKit 相关 HTTP 文件服务能力。", "sink", nil, nil), + } +} + +func nodeType(t, label, category, icon, description, role string, defaults map[string]any, params []graphNodeParam) graphNodeTypeInfo { + if defaults == nil { + defaults = map[string]any{} + } + defaults["id"] = t + defaults["type"] = t + defaults["role"] = role + defaults["enable"] = true + return graphNodeTypeInfo{Type: t, Label: label, Category: category, Icon: icon, Description: description, Defaults: defaults, Params: params} +} + +func textParam(key, label, placeholder string) graphNodeParam { + return graphNodeParam{Key: key, Label: label, Type: "text", Placeholder: placeholder} +} + +func numberParam(key, label, step string) graphNodeParam { + return graphNodeParam{Key: key, Label: label, Type: "number", Step: step} +} + +func boolParam(key, label string) graphNodeParam { + return graphNodeParam{Key: key, Label: label, Type: "boolean"} +} + +func selectParam(key, label string, options []string) graphNodeParam { + return graphNodeParam{Key: key, Label: label, Type: "select", Options: options} +} + +func faceDetParams() []graphNodeParam { + return []graphNodeParam{ + textParam("model_path", "模型路径", ""), + numberParam("infer_fps", "推理帧率", "0.1"), + numberParam("model_w", "模型宽度", "1"), + numberParam("model_h", "模型高度", "1"), + numberParam("conf_thresh", "置信度", "0.01"), + numberParam("nms_thresh", "NMS", "0.01"), + numberParam("max_faces", "最大人脸数", "1"), + } +} + +func knownGraphNodeTypes() map[string]bool { + out := map[string]bool{} + for _, item := range graphNodeTypesCatalog() { + out[item.Type] = true + } + return out +} diff --git a/internal/web/ui.go b/internal/web/ui.go index c50481d..826b94f 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -47,46 +47,50 @@ type PageData struct { OfflineCount int FoundCount int - Devices []*models.Device - DeviceRows []DeviceOverviewRow - AttentionDevices []*models.Device - Found []*models.Device - Device *models.Device - ConfigStatus *ConfigStatusView - ConfigStatusText string - ConfigStatusErr string - ConfigSources service.ConfigPreviewSources - ConfigPreview *service.ConfigPreviewResult - ResultTitle string - SelectedTemplate string - SelectedProfile string - SelectedOverlays []string - SelectedConfigID string - SelectedVersion string - Tasks []models.Task - Task *models.Task - TaskDeviceRows []TaskDeviceRow - Templates []service.Template - Template *service.Template - AssetTab string - AssetTemplates []service.ConfigTemplateAsset - AssetTemplate *service.ConfigTemplateAsset - AssetProfiles []service.ConfigProfileAsset - AssetProfile *service.ConfigProfileAsset - AssetProfileEditor *service.ConfigProfileEditor - AssetOverlays []service.ConfigOverlayAsset - AssetOverlay *service.ConfigOverlayAsset - AssetInstanceCount int - SelectedDeviceIDs []string - SelectedDevices []*models.Device - SelectedQuery string - SelectedDevicesURL string - BatchConfigURL string - ReloadSummary string - RollbackSummary string - AuditEntries []storage.AuditLogRecord - PersistedConfig *storage.DeviceConfigStateRecord - DBPath string + Devices []*models.Device + DeviceRows []DeviceOverviewRow + AttentionDevices []*models.Device + Found []*models.Device + Device *models.Device + ConfigStatus *ConfigStatusView + ConfigStatusText string + ConfigStatusErr string + ConfigSources service.ConfigPreviewSources + ConfigPreview *service.ConfigPreviewResult + ResultTitle string + SelectedTemplate string + SelectedProfile string + SelectedOverlays []string + SelectedConfigID string + SelectedVersion string + Tasks []models.Task + Task *models.Task + TaskDeviceRows []TaskDeviceRow + Templates []service.Template + Template *service.Template + AssetTab string + AssetTemplates []service.ConfigTemplateAsset + AssetTemplate *service.ConfigTemplateAsset + AssetProfiles []service.ConfigProfileAsset + AssetProfile *service.ConfigProfileAsset + AssetProfileEditor *service.ConfigProfileEditor + AssetOverlays []service.ConfigOverlayAsset + AssetOverlay *service.ConfigOverlayAsset + AssetInstanceCount int + SelectedDeviceIDs []string + SelectedDevices []*models.Device + SelectedQuery string + SelectedDevicesURL string + BatchConfigURL string + ReloadSummary string + RollbackSummary string + TemplateDraftName string + TemplateDraftDescription string + TemplateCloneSource string + TemplateCreateMode string + AuditEntries []storage.AuditLogRecord + PersistedConfig *storage.DeviceConfigStateRecord + DBPath string RawJSON string RawText string @@ -158,6 +162,9 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic b, _ := json.MarshalIndent(v, "", " ") return string(b) }, + "rawHTML": func(v string) template.HTML { + return template.HTML(v) + }, "hasString": func(items []string, want string) bool { for _, item := range items { if item == want { @@ -382,6 +389,7 @@ func tablerIconSVG(name string) string { "logs": ``, "meta": ``, "template": ``, + "edit": ``, "profile": ``, "overlay": ``, "release": ``, @@ -405,6 +413,8 @@ func (u *UI) Routes() (chi.Router, error) { assetHandler := http.StripPrefix("/assets/", http.FileServer(http.FS(assets))) r.Handle("/assets/*", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { p := req.URL.Path + w.Header().Set("Cache-Control", "no-store, max-age=0") + w.Header().Set("Pragma", "no-cache") switch { case strings.HasSuffix(p, ".css"): w.Header().Set("Content-Type", "text/css; charset=utf-8") @@ -424,7 +434,12 @@ func (u *UI) Routes() (chi.Router, error) { r.Get("/assets", u.pageAssets) r.Post("/assets/import", u.actionAssetsImport) r.Get("/assets/templates", u.pageAssetTemplates) + r.Post("/assets/templates/create", u.actionAssetTemplateCreate) r.Get("/assets/templates/{name}", u.pageAssetTemplate) + r.Post("/assets/templates/{name}/rename", u.actionAssetTemplateRename) + r.Post("/assets/templates/{name}/delete", u.actionAssetTemplateDelete) + 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/profiles", u.pageAssetProfiles) r.Get("/assets/profiles/{name}", u.pageAssetProfile) @@ -437,6 +452,7 @@ func (u *UI) Routes() (chi.Router, error) { r.Get("/system", u.pageSystem) r.Get("/system/db-backup", u.pageSystemDBBackup) r.Post("/system/db-restore", u.actionSystemDBRestore) + r.Get("/api/graph-node-types", u.apiGraphNodeTypes) r.Get("/device-config", u.pageDeviceConfig) r.Get("/device-config/{id}", u.pageDeviceConfigDetail) r.Get("/devices-add", u.pageDeviceAdd) @@ -1177,12 +1193,31 @@ func (u *UI) actionAssetsImport(w http.ResponseWriter, r *http.Request) { return } data = u.assetPageData("overview") - data.Message = fmt.Sprintf("已导入 %d 个模板、%d 个业务配置、%d 个叠加项", result.Templates, result.Profiles, result.Overlays) + data.Message = fmt.Sprintf("已导入 %d 个业务配置、%d 个叠加项。标准模板保持目录只读,不写入数据库。", result.Profiles, result.Overlays) u.render(w, r, "assets", data) } func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("templates") + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + data.TemplateDraftName = "new_template" + data.TemplateCreateMode = strings.TrimSpace(r.URL.Query().Get("mode")) + if data.TemplateCreateMode == "blank" { + data.TemplateDraftDescription = "从空白流程开始。仅建议在明确了解节点依赖和处理链约束时使用。" + } + if cloneName := strings.TrimSpace(r.URL.Query().Get("clone")); cloneName != "" { + if item, err := u.preview.GetTemplateAsset(cloneName); err == nil && item != nil { + data.TemplateCloneSource = item.Name + data.TemplateCreateMode = "clone" + data.TemplateDraftName = item.Name + "_copy" + data.TemplateDraftDescription = item.Description + } else if data.Error == "" && err != nil { + data.Error = err.Error() + } + } if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { if item, err := u.preview.GetTemplateAsset(name); err == nil { data.AssetTemplate = item @@ -1199,9 +1234,73 @@ func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) { u.render(w, r, "asset_templates", data) } +func (u *UI) actionAssetTemplateCreate(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + name, err := normalizeConfigName(r.FormValue("name")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + name = strings.TrimSuffix(name, ".json") + description := strings.TrimSpace(r.FormValue("description")) + cloneSource := strings.TrimSpace(r.FormValue("clone_source")) + + 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{}, + }, + } + } + + doc["name"] = name + doc["description"] = description + body, err := json.MarshalIndent(doc, "", " ") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := u.preview.SaveTemplateAsset(name, description, string(body)+"\n"); err != nil { + status := http.StatusInternalServerError + if strings.Contains(err.Error(), "read-only") || strings.Contains(err.Error(), "copy it before editing") { + status = http.StatusBadRequest + } + http.Error(w, err.Error(), status) + return + } + http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(name)+"/graph?msg="+urlQueryEscape("模板已创建"), http.StatusFound) +} + func (u *UI) pageAssetTemplate(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") data := u.assetPageData("templates") + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } item, err := u.preview.GetTemplateAsset(name) if err != nil { http.NotFound(w, r) @@ -1211,6 +1310,198 @@ func (u *UI) pageAssetTemplate(w http.ResponseWriter, r *http.Request) { u.render(w, r, "asset_templates", data) } +func (u *UI) pageAssetTemplateGraph(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + data := u.assetPageData("templates") + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + if u.preview == nil { + data.Error = "配置资产服务未初始化" + u.render(w, r, "asset_templates", data) + return + } + item, err := u.preview.GetTemplateAsset(name) + if err != nil { + http.NotFound(w, r) + return + } + data.Title = "模板可视化编辑" + if item.ReadOnly { + data.Title = "标准模板可视化预览" + } + data.AssetTemplate = item + raw, err := compactJSON(item.Raw) + if err != nil { + data.Error = err.Error() + u.render(w, r, "asset_templates", data) + return + } + data.RawJSON = raw + u.render(w, r, "asset_template_graph", data) +} + +func (u *UI) actionAssetTemplateGraphSave(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + raw := strings.TrimSpace(r.FormValue("json")) + if raw == "" { + http.Error(w, "template json is required", http.StatusBadRequest) + return + } + var doc map[string]any + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + http.Error(w, "invalid template json: "+err.Error(), http.StatusBadRequest) + return + } + if err := validateTemplateGraphDocument(doc); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + targetName := name + if rawName := strings.TrimSpace(r.FormValue("name")); rawName != "" { + normalized, err := normalizeConfigName(rawName) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + targetName = strings.TrimSuffix(normalized, ".json") + } + description := strings.TrimSpace(r.FormValue("description")) + if description == "" { + description, _ = doc["description"].(string) + } + doc["name"] = targetName + doc["description"] = description + body, err := json.MarshalIndent(doc, "", " ") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if targetName != name { + err = u.preview.RenameTemplateAsset(name, targetName, description, string(body)+"\n") + } else { + err = u.preview.SaveTemplateAsset(name, description, string(body)+"\n") + } + if err != nil { + status := http.StatusInternalServerError + if strings.Contains(err.Error(), "read-only") || strings.Contains(err.Error(), "copy it before editing") { + status = http.StatusBadRequest + } + http.Error(w, err.Error(), status) + return + } + message := "模板已保存" + if targetName != name { + message = "模板已保存,名称已更新" + } + http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(targetName)+"/graph?msg="+urlQueryEscape(message), http.StatusFound) +} + +func (u *UI) actionAssetTemplateRename(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + item, err := u.preview.GetTemplateAsset(name) + if err != nil || item == nil { + http.NotFound(w, r) + return + } + targetName := name + if rawName := strings.TrimSpace(r.FormValue("name")); rawName != "" { + normalized, err := normalizeConfigName(rawName) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + targetName = strings.TrimSuffix(normalized, ".json") + } + description := strings.TrimSpace(r.FormValue("description")) + if description == "" { + description = item.Description + } + 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"] = description + body, err = json.MarshalIndent(doc, "", " ") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if targetName != name { + err = u.preview.RenameTemplateAsset(name, targetName, description, string(body)+"\n") + } else { + err = u.preview.SaveTemplateAsset(name, description, string(body)+"\n") + } + if err != nil { + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(targetName)+"&msg="+urlQueryEscape("模板已保存,名称已更新"), http.StatusFound) +} + +func (u *UI) actionAssetTemplateDelete(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + if err := u.preview.DeleteTemplateAsset(name); err != nil { + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/templates?msg="+urlQueryEscape("用户模板已删除"), http.StatusFound) +} + +func (u *UI) apiGraphNodeTypes(w http.ResponseWriter, r *http.Request) { + if body, ok := u.loadAgentGraphNodeTypes(); ok { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write(body) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"items": graphNodeTypesCatalog()}) +} + +func (u *UI) loadAgentGraphNodeTypes() ([]byte, bool) { + if u.agent == nil || u.registry == nil { + return nil, false + } + u.ensureDevicesLoaded() + for _, dev := range u.registry.GetDevices() { + if dev == nil || strings.TrimSpace(dev.IP) == "" || dev.AgentPort <= 0 { + continue + } + body, code, err := u.agent.Do(http.MethodGet, dev.IP, dev.AgentPort, "/v1/graph-node-types", nil) + if err != nil || code < 200 || code >= 300 { + continue + } + var payload struct { + Items []graphNodeTypeInfo `json:"items"` + } + if err := json.Unmarshal(body, &payload); err != nil || len(payload.Items) == 0 { + continue + } + return body, true + } + return nil, false +} + func (u *UI) pageAssetTemplateExport(w http.ResponseWriter, r *http.Request) { u.exportAssetJSON(w, r, "templates", chi.URLParam(r, "name")) } @@ -1354,7 +1645,7 @@ func (u *UI) profileEditorPageData(name string) (PageData, error) { if len(editor.Instances) > 0 && editor.Instances[0].Template != "" { data.SelectedTemplate = editor.Instances[0].Template } else { - data.SelectedTemplate = "workshop_face_shoe_alarm" + data.SelectedTemplate = "std_workshop_face_recognition_shoe_alarm" } return data, nil } @@ -1504,6 +1795,74 @@ func normalizeConfigName(name string) (string, error) { return name, nil } +func compactJSON(v any) (string, error) { + body, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(body), nil +} + +func validateTemplateGraphDocument(doc map[string]any) error { + knownTypes := knownGraphNodeTypes() + templateMap, ok := doc["template"].(map[string]any) + if !ok { + return fmt.Errorf("template must be an object") + } + nodes, ok := templateMap["nodes"].([]any) + if !ok { + return fmt.Errorf("template.nodes must be an array") + } + edges, ok := templateMap["edges"].([]any) + if !ok { + return fmt.Errorf("template.edges must be an array") + } + seen := map[string]bool{} + for _, item := range nodes { + node, ok := item.(map[string]any) + if !ok { + return fmt.Errorf("template node must be an object") + } + id := strings.TrimSpace(fmt.Sprint(node["id"])) + if id == "" { + return fmt.Errorf("template node id is required") + } + if seen[id] { + return fmt.Errorf("duplicate node id: %s", id) + } + seen[id] = true + nodeType := strings.TrimSpace(fmt.Sprint(node["type"])) + if nodeType == "" { + return fmt.Errorf("template node type is required: %s", id) + } + if !knownTypes[nodeType] { + return fmt.Errorf("unknown node type: %s", nodeType) + } + } + for _, item := range edges { + var from, to string + if edge, ok := item.([]any); ok { + if len(edge) < 2 { + return fmt.Errorf("edge must have from and to") + } + from = strings.TrimSpace(fmt.Sprint(edge[0])) + to = strings.TrimSpace(fmt.Sprint(edge[1])) + } else if edge, ok := item.(map[string]any); ok { + from = strings.TrimSpace(fmt.Sprint(edge["from"])) + to = strings.TrimSpace(fmt.Sprint(edge["to"])) + } else { + return fmt.Errorf("edge must be an array or object") + } + if from == "" || to == "" { + return fmt.Errorf("edge has empty endpoint") + } + if !seen[from] || !seen[to] { + return fmt.Errorf("edge references unknown node: %s -> %s", from, to) + } + } + return nil +} + func prettyJSON(raw []byte) string { var out bytes.Buffer if err := json.Indent(&out, raw, "", " "); err != nil { @@ -1608,7 +1967,7 @@ func (u *UI) actionDeviceConfigPreview(w http.ResponseWriter, r *http.Request) { ConfigVersion: strings.TrimSpace(r.FormValue("config_version")), } if req.Template == "" { - req.Template = "workshop_face_shoe_alarm" + req.Template = "std_workshop_face_recognition_shoe_alarm" } if req.Profile == "" { req.Profile = "local_3588_test" @@ -1706,7 +2065,7 @@ func (u *UI) configPreviewPageData(dev *models.Device) PageData { Title: "配置预览", Device: dev, ConfigSources: sources, - SelectedTemplate: "workshop_face_shoe_alarm", + SelectedTemplate: "std_workshop_face_recognition_shoe_alarm", SelectedProfile: "local_3588_test", SelectedOverlays: []string{"face_debug"}, SelectedConfigID: "preview_" + dev.DeviceID, @@ -1818,7 +2177,7 @@ func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEd } } if strings.TrimSpace(form.Get("add_instance")) == "1" { - templateName := "workshop_face_shoe_alarm" + templateName := "std_workshop_face_recognition_shoe_alarm" if len(out) > 0 && strings.TrimSpace(out[0].Template) != "" { templateName = strings.TrimSpace(out[0].Template) } @@ -1830,7 +2189,7 @@ func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEd if len(out) > 0 { fallbackTemplate := strings.TrimSpace(out[0].Template) if fallbackTemplate == "" { - fallbackTemplate = "workshop_face_shoe_alarm" + fallbackTemplate = "std_workshop_face_recognition_shoe_alarm" } for i := range out { if strings.TrimSpace(out[i].Template) == "" { diff --git a/internal/web/ui/assets/graph_editor.css b/internal/web/ui/assets/graph_editor.css new file mode 100644 index 0000000..62b2fab --- /dev/null +++ b/internal/web/ui/assets/graph_editor.css @@ -0,0 +1,38 @@ +.graph-editor{display:grid;grid-template-columns:210px minmax(0,1fr) 280px;gap:12px;min-height:760px} +.graph-sidebar,.graph-inspector{border:1px solid var(--border);border-radius:8px;background:var(--surface-soft);padding:12px} +.graph-sidebar h3,.graph-inspector h3{font-size:13px;margin:0 0 10px;font-weight:600} +.graph-node-palette-list{display:flex;flex-direction:column;gap:10px;max-height:720px;overflow:auto;padding-right:2px} +.graph-node-palette-category{font-size:11px;color:var(--muted);margin:0 0 6px} +.graph-node-palette{width:100%;padding:7px 8px;border:1px solid var(--border);border-radius:7px;background:#fff;margin:0 0 6px;cursor:pointer;font-size:12px;text-align:left;display:flex;align-items:center;justify-content:flex-start;gap:8px} +.graph-node-palette:hover{border-color:var(--border-strong);background:#f8fafc} +.graph-node-palette-icon{display:inline-flex;align-items:center;justify-content:center;width:25px;height:25px;border-radius:7px;background:#e0f2fe;color:#075985;font-size:10px;letter-spacing:0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;flex:0 0 auto} +.graph-node-palette-text{display:flex;flex-direction:column;align-items:flex-start;min-width:0;gap:1px} +.graph-node-palette-text span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.graph-node-palette-text small{font-size:10px;color:var(--muted);font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.graph-canvas-wrap{position:relative;border:1px solid var(--border);border-radius:8px;background:#fff;overflow:auto;height:min(80vh,980px);min-height:760px} +.graph-canvas-toolbar{position:absolute;top:10px;right:10px;z-index:2;display:flex;gap:8px} +.graph-canvas-toolbar .btn{background:rgba(255,255,255,.92);font-size:12px;padding:6px 10px} +.graph-canvas{display:block;min-width:100%;min-height:760px} +.graph-node rect{fill:#fff;stroke:#cbd5e1;stroke-width:1.4} +.graph-node{cursor:grab} +.graph-node text{font-size:12px;fill:#111827;pointer-events:none} +.graph-node .graph-node-icon{font-size:10px;fill:#0284c7;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} +.graph-node .graph-node-type{font-size:11px;fill:#6b7280} +.graph-node.selected rect{stroke:#2563eb;stroke-width:2} +.graph-edge{stroke:#94a3b8;stroke-width:1.8;fill:none;cursor:pointer} +.graph-edge-hit{stroke:transparent;stroke-width:16;fill:none;cursor:pointer} +.graph-edge.selected{stroke:#2563eb;stroke-width:2.2} +.graph-empty-inspector{font-size:12px;color:var(--muted)} +.graph-node-form,.graph-edge-form{display:flex;flex-direction:column;gap:10px} +.graph-node-form label,.graph-edge-form label{display:block;width:100%} +.graph-node-form label span,.graph-edge-form label span{display:block;font-size:11px;color:var(--muted);margin-bottom:4px} +.graph-node-form textarea,.graph-edge-form textarea{display:block;width:100%;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.45;resize:vertical;min-height:96px} +.graph-inspector-title{font-size:12px;color:#0f172a;margin-top:2px} +.graph-form-hint{font-size:11px;color:var(--muted);line-height:1.45} +.graph-typed-param-fields{display:flex;flex-direction:column;gap:10px} +.graph-typed-param-fields:empty::before{content:"此节点暂无常用参数";display:block;font-size:11px;color:var(--muted);padding:8px 0} +.graph-advanced-json{border-top:1px solid var(--border);padding-top:8px} +.graph-advanced-json label{display:block;width:100%} +.graph-advanced-json summary{cursor:pointer;font-size:12px;color:#0f172a;margin-bottom:8px} +.graph-save-form{display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap} +.graph-save-form input[type="text"]{min-width:180px;max-width:240px} diff --git a/internal/web/ui/assets/graph_editor.js b/internal/web/ui/assets/graph_editor.js new file mode 100644 index 0000000..cc3c9d6 --- /dev/null +++ b/internal/web/ui/assets/graph_editor.js @@ -0,0 +1,806 @@ +(function () { + const root = document.querySelector(".graph-editor"); + const jsonEl = document.getElementById("graph-template-json"); + if (!root || !jsonEl) return; + + const svg = root.querySelector(".graph-canvas"); + const canvasWrap = root.querySelector(".graph-canvas-wrap"); + const nodeForm = root.querySelector(".graph-node-form"); + const edgeForm = root.querySelector(".graph-edge-form"); + const empty = root.querySelector(".graph-empty-inspector"); + const saveForm = root.closest(".graph-editor-card").querySelector(".graph-save-form"); + const deleteNodeBtn = root.querySelector(".graph-delete-node"); + const deleteEdgeBtn = root.querySelector(".graph-delete-edge"); + const connectBtn = root.querySelector(".graph-connect-node"); + const connectTarget = root.querySelector(".graph-connect-target"); + const typedParamFields = root.querySelector(".graph-typed-param-fields"); + const autoLayoutBtn = root.querySelector(".graph-auto-layout"); + const paletteList = root.querySelector(".graph-node-palette-list"); + const rawJSON = (jsonEl.textContent && jsonEl.textContent.trim()) + || (jsonEl.innerHTML && jsonEl.innerHTML.trim()) + || (jsonEl.content && jsonEl.content.textContent && jsonEl.content.textContent.trim()) + || "{}"; + const doc = JSON.parse(rawJSON); + const graph = doc.template || {}; + const nodes = Array.isArray(graph.nodes) ? graph.nodes : []; + const edges = Array.isArray(graph.edges) ? graph.edges : []; + graph.nodes = nodes; + graph.edges = edges; + doc.template = graph; + + const fallbackNodeTypes = [ + { type: "input_rtsp", label: "RTSP 输入", category: "输入", icon: "camera", description: "从网络摄像机或流媒体地址读取视频流。", defaults: { id: "input_rtsp", type: "input_rtsp", role: "source", enable: true, url: "${rtsp_url}" } }, + { type: "input_file", label: "文件输入", category: "输入", icon: "file", description: "从本地视频文件读取帧,常用于离线验证和回放。", defaults: { id: "input_file", type: "input_file", role: "source", enable: true } }, + { type: "preprocess", label: "图像预处理", category: "处理", icon: "adjust", description: "调整尺寸、格式和硬件加速路径,为后续推理或编码准备图像。", defaults: { id: "preprocess", type: "preprocess", role: "filter", enable: true, dst_format: "rgb" } }, + { type: "ai_scrfd", label: "SCRFD 人脸检测", category: "AI 推理", icon: "scan-face", description: "使用 SCRFD 模型做人脸检测,适合固定输入尺寸场景。", defaults: { id: "ai_scrfd", type: "ai_scrfd", role: "filter", enable: true } }, + { type: "ai_scrfd_sliding", label: "滑窗人脸检测", category: "AI 推理", icon: "scan-face", description: "使用滑窗方式执行 SCRFD 人脸检测,适合高分辨率画面。", defaults: { id: "ai_scrfd_sliding", type: "ai_scrfd_sliding", role: "filter", enable: true } }, + { type: "ai_face_det", label: "人脸检测", category: "AI 推理", icon: "face", description: "通用人脸检测节点,输出人脸框和质量信息。", defaults: { id: "ai_face_det", type: "ai_face_det", role: "filter", enable: true } }, + { type: "ai_face_recog", label: "人脸识别", category: "AI 推理", icon: "face-id", description: "对检测到的人脸进行特征提取和人脸库匹配。", defaults: { id: "face_recog", type: "ai_face_recog", role: "filter", enable: true, infer_fps: 2 } }, + { type: "ai_yolo", label: "YOLO 目标检测", category: "AI 推理", icon: "target", description: "使用 YOLO 模型检测人员、PPE 或其他目标。", defaults: { id: "ai_yolo", type: "ai_yolo", role: "filter", enable: true, infer_fps: 2, conf: 0.35, nms: 0.45 } }, + { type: "ai_shoe_det", label: "鞋靴检测", category: "AI 推理", icon: "shoe", description: "检测鞋靴和工鞋相关目标,可配合逻辑节点判断违规。", defaults: { id: "ai_shoe_det", type: "ai_shoe_det", role: "filter", enable: true } }, + { type: "tracker", label: "目标跟踪", category: "处理", icon: "route", description: "对检测目标分配跟踪 ID,保持跨帧目标状态。", defaults: { id: "tracker", type: "tracker", role: "filter", enable: true } }, + { type: "logic_gate", label: "规则判断", category: "规则", icon: "branch", description: "根据检测、跟踪或颜色分析结果进行业务规则判断。", defaults: { id: "logic_gate", type: "logic_gate", role: "filter", enable: true, expression: "" } }, + { type: "event_fusion", label: "事件融合", category: "规则", icon: "merge", description: "融合多路事件,减少重复告警并形成更稳定的业务事件。", defaults: { id: "event_fusion", type: "event_fusion", role: "filter", enable: true } }, + { type: "region_event", label: "区域事件", category: "规则", icon: "region", description: "基于区域、越线或停留规则生成区域行为事件。", defaults: { id: "region_event", type: "region_event", role: "filter", enable: true } }, + { type: "action_recog", label: "行为识别", category: "AI 推理", icon: "activity", description: "识别人员行为或动作事件。", defaults: { id: "action_recog", type: "action_recog", role: "filter", enable: true } }, + { type: "det_post", label: "检测后处理", category: "处理", icon: "filter", description: "对检测结果做过滤、映射、合并或类别转换。", defaults: { id: "det_post", type: "det_post", role: "filter", enable: true } }, + { type: "osd", label: "画面叠加", category: "输出", icon: "overlay", description: "在视频帧上绘制检测框、文字、人脸识别和事件信息。", defaults: { id: "osd", type: "osd", role: "filter", enable: true } }, + { type: "publish", label: "视频输出", category: "输出", icon: "broadcast", description: "编码并发布 RTSP、HLS 或其他视频输出。", defaults: { id: "publish", type: "publish", role: "sink", enable: true } }, + { type: "storage", label: "本地存储", category: "输出", icon: "database", description: "保存帧、事件或中间结果到本地存储。", defaults: { id: "storage", type: "storage", role: "sink", enable: true } }, + { type: "alarm", label: "告警动作", category: "输出", icon: "bell", description: "根据规则触发日志、抓图、录像片段、外部接口等动作。", defaults: { id: "alarm", type: "alarm", role: "sink", enable: true, level: "warning" } }, + { type: "gate", label: "流控闸门", category: "系统", icon: "gate", description: "控制流程分支或限流,保护下游节点。", defaults: { id: "gate", type: "gate", role: "filter", enable: true } }, + { type: "zlm_http", label: "ZLMediaKit HTTP", category: "系统", icon: "server", description: "提供 ZLMediaKit 相关 HTTP 文件服务能力。", defaults: { id: "zlm_http", type: "zlm_http", role: "sink", enable: true } } + ]; + + const coreNodeKeys = new Set(["id", "type", "role", "enable"]); + const coreEdgeKeys = new Set(["from", "to"]); + const paramSchemas = { + input_rtsp: [ + { key: "url", label: "RTSP 地址", type: "text", placeholder: "${rtsp_url}" }, + { key: "fps", label: "输入帧率", type: "number", step: "1" }, + { key: "width", label: "宽度", type: "number", step: "1" }, + { key: "height", label: "高度", type: "number", step: "1" }, + { key: "force_tcp", label: "强制 TCP", type: "boolean" }, + { key: "reconnect_sec", label: "重连间隔秒", type: "number", step: "1" } + ], + preprocess: [ + { key: "dst_w", label: "输出宽度", type: "number", step: "1" }, + { key: "dst_h", label: "输出高度", type: "number", step: "1" }, + { key: "dst_format", label: "输出格式", type: "select", options: ["rgb", "nv12", "bgr"] }, + { key: "resize_mode", label: "缩放方式", type: "select", options: ["stretch", "letterbox"] }, + { key: "use_rga", label: "使用 RGA", type: "boolean" }, + { key: "rga_gate", label: "RGA 通道", type: "text" } + ], + ai_yolo: [ + { key: "model_path", label: "模型路径", type: "text" }, + { key: "infer_fps", label: "推理帧率", type: "number", step: "0.1" }, + { key: "model_w", label: "模型宽度", type: "number", step: "1" }, + { key: "model_h", label: "模型高度", type: "number", step: "1" }, + { key: "conf", label: "置信度", type: "number", step: "0.01" }, + { key: "nms", label: "NMS", type: "number", step: "0.01" } + ], + ai_shoe_det: [ + { key: "model_path", label: "模型路径", type: "text" }, + { key: "infer_fps", label: "推理帧率", type: "number", step: "0.1" }, + { key: "conf", label: "置信度", type: "number", step: "0.01" }, + { key: "nms", label: "NMS", type: "number", step: "0.01" }, + { key: "append_detections", label: "追加检测结果", type: "boolean" } + ], + ai_scrfd_sliding: [ + { key: "model_path", label: "模型路径", type: "text" }, + { key: "infer_fps", label: "推理帧率", type: "number", step: "0.1" }, + { key: "conf_thresh", label: "置信度", type: "number", step: "0.01" }, + { key: "nms_thresh", label: "NMS", type: "number", step: "0.01" }, + { key: "max_faces", label: "最大人脸数", type: "number", step: "1" } + ], + ai_face_recog: [ + { key: "model_path", label: "模型路径", type: "text" }, + { key: "infer_fps", label: "推理帧率", type: "number", step: "0.1" }, + { key: "align", label: "人脸对齐", type: "boolean" }, + { key: "emit_embedding", label: "输出特征", type: "boolean" }, + { key: "max_faces", label: "最大人脸数", type: "number", step: "1" } + ], + tracker: [ + { key: "mode", label: "跟踪模式", type: "text" }, + { key: "per_class", label: "按类别跟踪", type: "boolean" }, + { key: "high_th", label: "高阈值", type: "number", step: "0.01" }, + { key: "low_th", label: "低阈值", type: "number", step: "0.01" }, + { key: "iou_th", label: "IOU 阈值", type: "number", step: "0.01" }, + { key: "max_age_ms", label: "最大保留毫秒", type: "number", step: "1" } + ], + logic_gate: [ + { key: "mode", label: "逻辑模式", type: "text" }, + { key: "debug", label: "调试输出", type: "boolean" }, + { key: "anchor_class", label: "锚点类别", type: "number", step: "1" }, + { key: "boots_class", label: "鞋靴类别", type: "number", step: "1" }, + { key: "violation_class", label: "违规类别", type: "number", step: "1" } + ], + osd: [ + { key: "draw_bbox", label: "绘制框", type: "boolean" }, + { key: "draw_text", label: "绘制文字", type: "boolean" }, + { key: "draw_face_det", label: "绘制人脸检测", type: "boolean" }, + { key: "draw_face_recog", label: "绘制人脸识别", type: "boolean" }, + { key: "line_width", label: "线宽", type: "number", step: "0.1" }, + { key: "font_scale", label: "字体缩放", type: "number", step: "0.1" } + ], + publish: [ + { key: "codec", label: "编码", type: "select", options: ["h264", "h265"] }, + { key: "fps", label: "输出帧率", type: "number", step: "1" }, + { key: "gop", label: "GOP", type: "number", step: "1" }, + { key: "bitrate_kbps", label: "码率 kbps", type: "number", step: "1" }, + { key: "use_mpp", label: "使用 MPP", type: "boolean" }, + { key: "use_ffmpeg_mux", label: "FFmpeg 封装", type: "boolean" } + ], + alarm: [ + { key: "eval_fps", label: "评估帧率", type: "number", step: "0.1" } + ] + }; + + function catalogFromItems(items) { + const out = {}; + (items || []).forEach((item) => { + if (!item || !item.type || !item.defaults) return; + out[item.type] = item; + if (Array.isArray(item.params)) paramSchemas[item.type] = item.params; + }); + return out; + } + + let nodeCatalog = catalogFromItems(fallbackNodeTypes); + + const layout = (((doc.ui || {}).layout || {}).nodes) || {}; + const hasSavedLayout = Object.keys(layout).length > 0; + const nodeSize = { w: 142, h: 48 }; + const layoutGap = { x: 190, y: 86 }; + const positions = {}; + let selectedNodeId = ""; + let selectedEdgeIndex = -1; + let drag = null; + + function nodePosition(node, index) { + const saved = layout[node.id] || {}; + return { + x: Number.isFinite(saved.x) ? saved.x : 80 + (index % 4) * 220, + y: Number.isFinite(saved.y) ? saved.y : 80 + Math.floor(index / 4) * 100 + }; + } + + nodes.forEach((node, index) => { + positions[node.id] = nodePosition(node, index); + }); + + function edgeEndpoints(edge) { + if (Array.isArray(edge)) return { from: edge[0], to: edge[1] }; + return { from: edge.from, to: edge.to }; + } + + function setEdgeEndpoint(edge, key, value) { + if (Array.isArray(edge)) { + edge[key === "from" ? 0 : 1] = value; + return; + } + edge[key] = value; + } + + function edgeExtras(edge) { + if (Array.isArray(edge)) { + const extra = edge[2]; + return extra && typeof extra === "object" && !Array.isArray(extra) ? extra : {}; + } + const out = {}; + Object.entries(edge).forEach(([key, value]) => { + if (!coreEdgeKeys.has(key)) out[key] = value; + }); + return out; + } + + function ensureObjectEdge(index) { + const edge = edges[index]; + if (!Array.isArray(edge)) return edge; + const next = { from: edge[0], to: edge[1] }; + edges[index] = next; + return next; + } + + function clear(el) { + while (el.firstChild) el.removeChild(el.firstChild); + } + + function svgEl(name, attrs) { + const el = document.createElementNS("http://www.w3.org/2000/svg", name); + Object.entries(attrs || {}).forEach(([key, value]) => el.setAttribute(key, String(value))); + return el; + } + + function iconLabel(icon, type) { + const icons = { + camera: "IN", + file: "FI", + adjust: "PR", + "scan-face": "FD", + face: "FD", + "face-id": "FR", + target: "AI", + shoe: "SH", + route: "TR", + branch: "LG", + merge: "EF", + region: "RG", + activity: "AC", + filter: "PO", + overlay: "OS", + broadcast: "PB", + database: "DB", + bell: "AL", + gate: "GT", + server: "SV" + }; + return icons[icon] || String(type || "?").slice(0, 2).toUpperCase(); + } + + function renderPalette() { + if (!paletteList) return; + clear(paletteList); + const groups = {}; + Object.values(nodeCatalog).forEach((item) => { + const key = item.category || "其他"; + groups[key] = groups[key] || []; + groups[key].push(item); + }); + Object.keys(groups).forEach((category) => { + const group = document.createElement("div"); + group.className = "graph-node-palette-group"; + const title = document.createElement("div"); + title.className = "graph-node-palette-category"; + title.textContent = category; + group.appendChild(title); + groups[category].forEach((item) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "graph-node-palette"; + button.dataset.nodeType = item.type; + button.title = item.description || item.label || item.type; + const icon = document.createElement("span"); + icon.className = "graph-node-palette-icon"; + icon.textContent = iconLabel(item.icon, item.type); + const text = document.createElement("span"); + text.className = "graph-node-palette-text"; + const label = document.createElement("span"); + label.textContent = item.label || item.type; + const type = document.createElement("small"); + type.textContent = item.type; + text.appendChild(label); + text.appendChild(type); + button.appendChild(icon); + button.appendChild(text); + group.appendChild(button); + }); + paletteList.appendChild(group); + }); + } + + async function loadNodeCatalog() { + if (!paletteList || !paletteList.dataset.catalogUrl || !window.fetch) return; + try { + const res = await fetch(paletteList.dataset.catalogUrl, { headers: { Accept: "application/json" } }); + if (!res.ok) return; + const payload = await res.json(); + if (!payload || !Array.isArray(payload.items) || payload.items.length === 0) return; + nodeCatalog = catalogFromItems(payload.items); + renderPalette(); + render(); + if (selectedNodeId) selectNode(selectedNodeId); + } catch (err) { + // Keep the offline catalog available when no device agent is reachable. + } + } + + function nodeOptions(select, excludedId) { + clear(select); + nodes.forEach((node) => { + if (node.id === excludedId) return; + const option = document.createElement("option"); + option.value = node.id; + option.textContent = node.id; + select.appendChild(option); + }); + } + + function anchorPoints(fromId, toId) { + const a = positions[fromId]; + const b = positions[toId]; + if (!a || !b) return null; + const dx = b.x - a.x; + const dy = b.y - a.y; + if (Math.abs(dy) >= Math.abs(dx)) { + if (dy >= 0) { + return { + from: { x: a.x + nodeSize.w / 2, y: a.y + nodeSize.h }, + to: { x: b.x + nodeSize.w / 2, y: b.y }, + vertical: true + }; + } + return { + from: { x: a.x + nodeSize.w / 2, y: a.y }, + to: { x: b.x + nodeSize.w / 2, y: b.y + nodeSize.h }, + vertical: true + }; + } + if (dx >= 0) { + return { + from: { x: a.x + nodeSize.w, y: a.y + nodeSize.h / 2 }, + to: { x: b.x, y: b.y + nodeSize.h / 2 }, + vertical: false + }; + } + return { + from: { x: a.x, y: a.y + nodeSize.h / 2 }, + to: { x: b.x + nodeSize.w, y: b.y + nodeSize.h / 2 }, + vertical: false + }; + } + + function edgePathData(fromId, toId) { + const anchors = anchorPoints(fromId, toId); + if (!anchors) return ""; + const a = anchors.from; + const b = anchors.to; + if (anchors.vertical) { + const midY = (a.y + b.y) / 2; + return `M ${a.x} ${a.y} C ${a.x} ${midY}, ${b.x} ${midY}, ${b.x} ${b.y}`; + } + const midX = (a.x + b.x) / 2; + return `M ${a.x} ${a.y} C ${midX} ${a.y}, ${midX} ${b.y}, ${b.x} ${b.y}`; + } + + function updateCanvasSize() { + const padding = 90; + let maxX = 0; + let maxY = 0; + nodes.forEach((node) => { + const p = positions[node.id]; + if (!p) return; + maxX = Math.max(maxX, p.x + nodeSize.w); + maxY = Math.max(maxY, p.y + nodeSize.h); + }); + const contentWidth = Math.max(720, maxX + padding); + const contentHeight = Math.max(620, maxY + padding); + const viewportWidth = canvasWrap ? Math.max(720, canvasWrap.clientWidth - 2) : 720; + svg.setAttribute("width", String(Math.max(viewportWidth, contentWidth))); + svg.setAttribute("height", String(contentHeight)); + svg.style.width = `${Math.max(viewportWidth, contentWidth)}px`; + svg.style.height = `${contentHeight}px`; + svg.setAttribute("viewBox", `0 0 ${Math.max(viewportWidth, contentWidth)} ${contentHeight}`); + } + + function render() { + updateCanvasSize(); + clear(svg); + edges.forEach((edge, index) => { + const ep = edgeEndpoints(edge); + const d = edgePathData(ep.from, ep.to); + if (!d) return; + const hit = svgEl("path", { class: "graph-edge-hit", d: d, "data-edge-index": index }); + const path = svgEl("path", { + class: "graph-edge" + (index === selectedEdgeIndex ? " selected" : ""), + d: d, + "data-edge-index": index + }); + hit.addEventListener("click", () => selectEdge(index)); + path.addEventListener("click", () => selectEdge(index)); + svg.appendChild(hit); + svg.appendChild(path); + }); + + nodes.forEach((node) => { + const p = positions[node.id] || { x: 80, y: 80 }; + const g = svgEl("g", { + class: "graph-node" + (node.id === selectedNodeId ? " selected" : ""), + transform: `translate(${p.x}, ${p.y})`, + "data-node-id": node.id + }); + g.appendChild(svgEl("rect", { width: nodeSize.w, height: nodeSize.h, rx: 7 })); + const spec = nodeCatalog[node.type] || {}; + const svgTitle = svgEl("title"); + svgTitle.textContent = spec.description || node.type || node.id || ""; + g.appendChild(svgTitle); + const icon = svgEl("text", { x: 12, y: 19, class: "graph-node-icon" }); + icon.textContent = iconLabel(spec.icon, node.type); + g.appendChild(icon); + const title = svgEl("text", { x: 36, y: 19 }); + title.textContent = node.id || "-"; + g.appendChild(title); + const meta = svgEl("text", { x: 36, y: 37, class: "graph-node-type" }); + meta.textContent = spec.label || node.type || "-"; + g.appendChild(meta); + g.addEventListener("click", () => selectNode(node.id)); + g.addEventListener("pointerdown", (event) => startDrag(event, node)); + svg.appendChild(g); + }); + } + + function showPanel(kind) { + empty.hidden = kind !== "empty"; + nodeForm.hidden = kind !== "node"; + edgeForm.hidden = kind !== "edge"; + } + + function nodeExtras(node) { + const schemaKeys = new Set((paramSchemas[node.type] || []).map((field) => field.key)); + const out = {}; + Object.entries(node).forEach(([key, value]) => { + if (!coreNodeKeys.has(key) && !schemaKeys.has(key)) out[key] = value; + }); + return out; + } + + function fieldValueForInput(value) { + if (value === undefined || value === null) return ""; + if (typeof value === "object") return JSON.stringify(value); + return String(value); + } + + function parseTypedValue(field, value) { + if (field.type === "boolean") return value === "true"; + if (field.type === "number") { + if (String(value).trim() === "") return undefined; + const num = Number(value); + return Number.isFinite(num) ? num : undefined; + } + if (String(value).trim() === "") return undefined; + return value; + } + + function createTypedField(field, value) { + const label = document.createElement("label"); + const caption = document.createElement("span"); + caption.textContent = field.label; + label.appendChild(caption); + let input; + if (field.type === "boolean") { + input = document.createElement("select"); + [["true", "启用"], ["false", "停用"]].forEach(([val, text]) => { + const option = document.createElement("option"); + option.value = val; + option.textContent = text; + input.appendChild(option); + }); + input.value = value === false ? "false" : "true"; + } else if (field.type === "select") { + input = document.createElement("select"); + (field.options || []).forEach((item) => { + const option = document.createElement("option"); + option.value = item; + option.textContent = item; + input.appendChild(option); + }); + if (value !== undefined) input.value = String(value); + } else { + input = document.createElement("input"); + input.type = field.type || "text"; + if (field.step) input.step = field.step; + if (field.placeholder) input.placeholder = field.placeholder; + input.value = fieldValueForInput(value); + } + input.dataset.paramKey = field.key; + input.dataset.paramType = field.type || "text"; + label.appendChild(input); + return label; + } + + function renderTypedParamFields(node) { + clear(typedParamFields); + (paramSchemas[node.type] || []).forEach((field) => { + typedParamFields.appendChild(createTypedField(field, node[field.key])); + }); + } + + function applyTypedParamFields(node) { + (paramSchemas[node.type] || []).forEach((field) => { + const input = typedParamFields.querySelector(`[data-param-key="${field.key}"]`); + if (!input) return; + const parsed = parseTypedValue(field, input.value); + if (parsed === undefined) { + delete node[field.key]; + } else { + node[field.key] = parsed; + } + }); + } + + function selectNode(id) { + selectedNodeId = id; + selectedEdgeIndex = -1; + const node = nodes.find((item) => item.id === id); + if (!node) return; + showPanel("node"); + nodeForm.elements.id.value = node.id || ""; + nodeForm.elements.type.value = node.type || ""; + nodeForm.elements.role.value = node.role || "filter"; + nodeForm.elements.enable.value = node.enable === false ? "false" : "true"; + renderTypedParamFields(node); + nodeForm.elements.params_json.value = JSON.stringify(nodeExtras(node), null, 2); + nodeOptions(connectTarget, node.id); + render(); + } + + function selectEdge(index) { + selectedNodeId = ""; + selectedEdgeIndex = index; + const edge = edges[index]; + if (!edge) return; + const ep = edgeEndpoints(edge); + showPanel("edge"); + nodeOptions(edgeForm.elements.from, ""); + nodeOptions(edgeForm.elements.to, ""); + edgeForm.elements.from.value = ep.from || ""; + edgeForm.elements.to.value = ep.to || ""; + edgeForm.elements.params_json.value = JSON.stringify(edgeExtras(edge), null, 2); + render(); + } + + function parseJSONField(text, fallback) { + const raw = text.trim(); + if (raw === "") return {}; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return fallback; + return parsed; + } + + function applyNodeExtras(node) { + applyTypedParamFields(node); + const extras = parseJSONField(nodeForm.elements.params_json.value, {}); + Object.keys(node).forEach((key) => { + if (!coreNodeKeys.has(key) && !(paramSchemas[node.type] || []).some((field) => field.key === key)) delete node[key]; + }); + Object.assign(node, extras); + } + + function applyEdgeExtras(index) { + const edge = ensureObjectEdge(index); + const extras = parseJSONField(edgeForm.elements.params_json.value, {}); + Object.keys(edge).forEach((key) => { + if (!coreEdgeKeys.has(key)) delete edge[key]; + }); + Object.assign(edge, extras); + } + + function bindForms() { + if (!nodeForm.dataset.bound) { + nodeForm.dataset.bound = "true"; + nodeForm.addEventListener("input", function (event) { + if (event.target && event.target.dataset && event.target.dataset.paramKey) { + const node = nodes.find((item) => item.id === selectedNodeId); + if (node) applyTypedParamFields(node); + return; + } + if (event.target && event.target.name === "params_json") return; + const node = nodes.find((item) => item.id === selectedNodeId); + if (!node) return; + const oldId = node.id; + const nextId = nodeForm.elements.id.value.trim(); + node.role = nodeForm.elements.role.value; + node.enable = nodeForm.elements.enable.value === "true"; + if (nextId && nextId !== oldId && !nodes.some((item) => item !== node && item.id === nextId)) { + node.id = nextId; + positions[nextId] = positions[oldId] || { x: 80, y: 80 }; + delete positions[oldId]; + edges.forEach((edge) => { + if (edgeEndpoints(edge).from === oldId) setEdgeEndpoint(edge, "from", nextId); + if (edgeEndpoints(edge).to === oldId) setEdgeEndpoint(edge, "to", nextId); + }); + selectedNodeId = nextId; + nodeOptions(connectTarget, node.id); + } + render(); + }); + nodeForm.elements.params_json.addEventListener("change", function () { + const node = nodes.find((item) => item.id === selectedNodeId); + if (!node) return; + try { + applyNodeExtras(node); + this.setCustomValidity(""); + } catch (err) { + this.setCustomValidity("参数必须是 JSON 对象"); + this.reportValidity(); + } + }); + } + + if (!edgeForm.dataset.bound) { + edgeForm.dataset.bound = "true"; + edgeForm.addEventListener("input", function (event) { + if (event.target && event.target.name === "params_json") return; + const edge = edges[selectedEdgeIndex]; + if (!edge) return; + setEdgeEndpoint(edge, "from", edgeForm.elements.from.value); + setEdgeEndpoint(edge, "to", edgeForm.elements.to.value); + render(); + }); + edgeForm.elements.params_json.addEventListener("change", function () { + if (selectedEdgeIndex < 0) return; + try { + applyEdgeExtras(selectedEdgeIndex); + this.setCustomValidity(""); + } catch (err) { + this.setCustomValidity("参数必须是 JSON 对象"); + this.reportValidity(); + } + }); + } + } + + function startDrag(event, node) { + const p = positions[node.id] || { x: 80, y: 80 }; + drag = { id: node.id, startX: event.clientX, startY: event.clientY, x: p.x, y: p.y }; + selectedNodeId = node.id; + selectNode(node.id); + event.preventDefault(); + } + + function uniqueNodeId(base) { + let out = base; + let i = 2; + while (nodes.some((node) => node.id === out)) { + out = `${base}_${i}`; + i += 1; + } + return out; + } + + function hasEdge(from, to) { + return edges.some((edge) => { + const ep = edgeEndpoints(edge); + return ep.from === from && ep.to === to; + }); + } + + function autoLayout() { + const ids = nodes.map((node) => node.id).filter(Boolean); + const viewportWidth = canvasWrap ? Math.max(720, canvasWrap.clientWidth - 2) : 720; + const indegree = {}; + const outgoing = {}; + ids.forEach((id) => { + indegree[id] = 0; + outgoing[id] = []; + }); + edges.forEach((edge) => { + const ep = edgeEndpoints(edge); + if (!(ep.from in indegree) || !(ep.to in indegree)) return; + outgoing[ep.from].push(ep.to); + indegree[ep.to] += 1; + }); + + const queue = ids.filter((id) => indegree[id] === 0); + const level = {}; + queue.forEach((id) => { level[id] = 0; }); + for (let i = 0; i < queue.length; i += 1) { + const id = queue[i]; + outgoing[id].forEach((next) => { + level[next] = Math.max(level[next] || 0, (level[id] || 0) + 1); + indegree[next] -= 1; + if (indegree[next] === 0) queue.push(next); + }); + } + ids.forEach((id, index) => { + if (level[id] === undefined) level[id] = index; + }); + + const groups = {}; + ids.forEach((id) => { + const key = level[id]; + groups[key] = groups[key] || []; + groups[key].push(id); + }); + Object.keys(groups).map(Number).sort((a, b) => a - b).forEach((levelNum) => { + const group = groups[levelNum]; + const totalWidth = nodeSize.w + Math.max(0, group.length - 1) * layoutGap.x; + const startX = Math.max(60, (viewportWidth - totalWidth) / 2); + group.forEach((id, index) => { + positions[id] = { + x: startX + index * layoutGap.x, + y: 70 + levelNum * layoutGap.y + }; + }); + }); + render(); + } + + if (paletteList) { + paletteList.addEventListener("click", function (event) { + const item = event.target.closest(".graph-node-palette"); + if (!item || !paletteList.contains(item)) return; + const spec = nodeCatalog[item.dataset.nodeType]; + if (!spec) return; + const node = JSON.parse(JSON.stringify(spec.defaults)); + node.id = uniqueNodeId(node.id); + nodes.push(node); + positions[node.id] = { x: 100 + nodes.length * 20, y: 100 + nodes.length * 20 }; + selectNode(node.id); + }); + } + + svg.addEventListener("pointermove", function (event) { + if (!drag) return; + positions[drag.id] = { + x: drag.x + event.clientX - drag.startX, + y: drag.y + event.clientY - drag.startY + }; + render(); + }); + + svg.addEventListener("pointerup", function () { + drag = null; + }); + + if (connectBtn) { + connectBtn.addEventListener("click", function () { + const target = connectTarget.value; + if (!selectedNodeId || !target || hasEdge(selectedNodeId, target)) return; + edges.push({ from: selectedNodeId, to: target }); + selectedEdgeIndex = edges.length - 1; + selectEdge(selectedEdgeIndex); + }); + } + + if (deleteNodeBtn) { + deleteNodeBtn.addEventListener("click", function () { + if (!selectedNodeId) return; + const index = nodes.findIndex((node) => node.id === selectedNodeId); + if (index >= 0) nodes.splice(index, 1); + for (let i = edges.length - 1; i >= 0; i -= 1) { + const ep = edgeEndpoints(edges[i]); + if (ep.from === selectedNodeId || ep.to === selectedNodeId) edges.splice(i, 1); + } + delete positions[selectedNodeId]; + selectedNodeId = ""; + showPanel("empty"); + render(); + }); + } + + if (deleteEdgeBtn) { + deleteEdgeBtn.addEventListener("click", function () { + if (selectedEdgeIndex < 0) return; + edges.splice(selectedEdgeIndex, 1); + selectedEdgeIndex = -1; + showPanel("empty"); + render(); + }); + } + + if (autoLayoutBtn) { + autoLayoutBtn.addEventListener("click", autoLayout); + } + + if (saveForm) { + saveForm.addEventListener("submit", function (event) { + try { + if (!nodeForm.hidden && selectedNodeId) { + const node = nodes.find((item) => item.id === selectedNodeId); + if (node) applyNodeExtras(node); + } + if (!edgeForm.hidden && selectedEdgeIndex >= 0) { + applyEdgeExtras(selectedEdgeIndex); + } + doc.ui = doc.ui || {}; + doc.ui.layout = doc.ui.layout || {}; + doc.ui.layout.version = 1; + doc.ui.layout.nodes = positions; + saveForm.elements.json.value = JSON.stringify(doc); + } catch (err) { + event.preventDefault(); + window.alert(err && err.message ? err.message : "参数 JSON 格式不正确"); + } + }); + } + + bindForms(); + renderPalette(); + loadNodeCatalog(); + if (!hasSavedLayout && nodes.length > 0) { + autoLayout(); + } else { + render(); + } +})(); diff --git a/internal/web/ui/assets/style.css b/internal/web/ui/assets/style.css index 27b1743..bd176d4 100644 --- a/internal/web/ui/assets/style.css +++ b/internal/web/ui/assets/style.css @@ -157,6 +157,14 @@ tbody tr:hover{background:#f9fafb} .info-list>div{padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface-soft)} .info-list span{display:block;margin-bottom:5px;font-size:12px;color:var(--muted)} .info-list strong{display:block;font-size:13px;font-weight:600;line-height:1.45} +.editable-line{display:flex;align-items:center;justify-content:space-between;gap:8px} +.editable-line strong{margin:0;flex:1 1 auto} +.icon-only{display:inline-flex;align-items:center;justify-content:center;padding:0;min-width:28px;width:28px;height:28px} +.icon-only .ui-icon{width:14px;height:14px;margin:0 auto} +.btn.ghost.icon-only{background:transparent;border-color:transparent;color:#64748b} +.btn.ghost.icon-only:hover{background:#eef2f7;border-color:#dbe1e8;color:#334155} +.inline-edit-form{display:flex;align-items:center;gap:8px} +.inline-edit-form input{flex:1 1 auto;min-width:0} .compact-list{grid-template-columns:1fr} .summary-strip{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px} diff --git a/internal/web/ui/templates/asset_template_graph.html b/internal/web/ui/templates/asset_template_graph.html new file mode 100644 index 0000000..d3efb4b --- /dev/null +++ b/internal/web/ui/templates/asset_template_graph.html @@ -0,0 +1,76 @@ +{{define "asset_template_graph"}} +{{template "asset_tabs" .}} +