Add visual template editor and refine template management

This commit is contained in:
tian 2026-04-28 18:45:28 +08:00
parent 28ef9c856e
commit d712e5628b
17 changed files with 2863 additions and 184 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 19l16 0"/><path d="M4 15l4 -6l4 2l4 -5l4 9"/></svg>`,
"meta": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 4m0 2a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M3 17m0 2a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 17m0 2a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M7 6h10"/><path d="M5 8v9"/><path d="M7 19h10"/><path d="M17 8v9"/></svg>`,
"template": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"/><path d="M9 8h6"/><path d="M9 12h6"/><path d="M9 16h4"/></svg>`,
"edit": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 6l3 3"/><path d="M7 18l3.5 -.5l8 -8a2.121 2.121 0 0 0 -3 -3l-8 8l-.5 3.5"/></svg>`,
"profile": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M12 3c2.755 0 5.455 .638 7.407 1.758a2 2 0 0 1 1.002 1.737v11.01a2 2 0 0 1 -1.002 1.737c-1.952 1.12 -4.652 1.758 -7.407 1.758s-5.455 -.638 -7.407 -1.758a2 2 0 0 1 -1.002 -1.737v-11.01a2 2 0 0 1 1.002 -1.737c1.952 -1.12 4.652 -1.758 7.407 -1.758z"/></svg>`,
"overlay": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 3.34l10 5.66v10l-10 -5.66z"/><path d="M17 9l4 -2.26l-10 -5.74l-4 2.26z"/><path d="M7 13l-4 -2.26v-6.74l4 -2.26"/></svg>`,
"release": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 7h10v10h-10z"/><path d="M3 7l3 0"/><path d="M18 7l3 0"/><path d="M7 3l0 3"/><path d="M7 18l0 3"/><path d="M17 18l0 3"/><path d="M17 3l0 3"/></svg>`,
@ -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) == "" {

View File

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

View File

@ -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();
}
})();

View File

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

View File

@ -0,0 +1,76 @@
{{define "asset_template_graph"}}
{{template "asset_tabs" .}}
<div class="card graph-editor-card">
<div class="section-title">
<div>
<div class="crumb">模板 / 可视化编辑</div>
<h2 class="title-with-icon">{{icon "template"}}<span>模板可视化编辑</span></h2>
</div>
<div class="actions compact">
{{if not .AssetTemplate.ReadOnly}}
<form class="graph-save-form" method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/graph">
<input type="hidden" name="json" />
<button type="submit" class="primary">{{icon "apply"}}<span>保存模板</span></button>
</form>
{{end}}
{{if .AssetTemplate.ReadOnly}}
<a class="btn secondary" href="/ui/assets/templates?clone={{.AssetTemplate.Name}}">{{icon "add"}}<span>复制后编辑</span></a>
{{end}}
<a class="btn ghost" href="/ui/assets/templates?name={{.AssetTemplate.Name}}">返回模板</a>
</div>
</div>
{{if .AssetTemplate.ReadOnly}}
<div class="form-hint">当前是标准模板预览页,画布内容可查看但不会直接保存。请先复制为用户模板再编辑。</div>
{{end}}
<div class="graph-editor" data-template-name="{{.AssetTemplate.Name}}">
<aside class="graph-sidebar">
<h3>节点库</h3>
<div class="graph-node-palette-list" data-catalog-url="/ui/api/graph-node-types"></div>
</aside>
<section class="graph-canvas-wrap">
<div class="graph-canvas-toolbar">
<button type="button" class="btn ghost graph-auto-layout">自动布局</button>
</div>
<svg class="graph-canvas" role="img" aria-label="模板流程图"></svg>
</section>
<aside class="graph-inspector">
<h3>属性面板</h3>
<div class="graph-empty-inspector">选择节点或连线后编辑参数</div>
<form class="graph-node-form" hidden>
<div class="graph-inspector-title">节点参数</div>
<label><span>节点 ID</span><input name="id" /></label>
<label><span>类型</span><input name="type" readonly /></label>
<label><span>角色</span><select name="role"><option>source</option><option>filter</option><option>sink</option></select></label>
<label><span>启用</span><select name="enable"><option value="true">启用</option><option value="false">停用</option></select></label>
<div class="graph-inspector-title">常用参数</div>
<div class="graph-typed-param-fields"></div>
<details class="graph-advanced-json" open>
<summary>高级 JSON</summary>
<label><span>未结构化参数</span><textarea class="graph-param-editor" name="params_json" rows="7"></textarea></label>
<div class="graph-form-hint">复杂对象和暂未建模字段保留在这里,会随模板一起保存。</div>
</details>
<div class="graph-inspector-title">连接</div>
<label><span>目标节点</span><select class="graph-connect-target" name="connect_target"></select></label>
<button type="button" class="secondary graph-connect-node">连接到目标节点</button>
<button type="button" class="danger graph-delete-node">删除节点</button>
</form>
<form class="graph-edge-form" hidden>
<div class="graph-inspector-title">连线参数</div>
<label><span>起点</span><select name="from"></select></label>
<label><span>终点</span><select name="to"></select></label>
<label><span>扩展参数 JSON</span><textarea class="graph-edge-param-editor" name="params_json" rows="7"></textarea></label>
<div class="graph-form-hint">连线用于表达数据流向,扩展参数可记录 stream、topic、条件等。</div>
<button type="button" class="danger graph-delete-edge">删除连线</button>
</form>
</aside>
</div>
<template id="graph-template-json">{{rawHTML .RawJSON}}</template>
<script src="/ui/assets/graph_editor.js"></script>
</div>
{{template "asset_tabs_end" .}}
{{end}}

View File

@ -4,6 +4,10 @@
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "template"}}<span>模板列表</span></h2>
<div class="form-hint">标准模板应作为基线,复制后再做定制化修改。空白模板仅用于高级场景。</div>
</div>
<div class="actions compact">
<a class="btn ghost" href="/ui/assets/templates?mode=blank">{{icon "add"}}<span>新建空白模板</span></a>
</div>
</div>
<div class="table-wrap">
@ -12,7 +16,7 @@
<tr>
<th>模板</th>
<th>描述</th>
<th>结构</th>
<th>流程结构</th>
<th>来源</th>
</tr>
</thead>
@ -21,8 +25,8 @@
<tr>
<td><a class="mono" href="/ui/assets/templates?name={{.Name}}">{{.Name}}</a></td>
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
<td class="mono">{{.NodeCount}} / {{.EdgeCount}}</td>
<td class="mono">{{if .Source}}{{.Source}}{{else}}-{{end}}</td>
<td>{{.NodeCount}}节点 / {{.EdgeCount}}连线</td>
<td>{{if .ReadOnly}}标准模板{{else}}用户模板{{end}}</td>
</tr>
{{else}}
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有模板</div></div></td></tr>
@ -32,6 +36,31 @@
</div>
</div>
{{if .TemplateCreateMode}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "template"}}<span>{{if eq .TemplateCreateMode "clone"}}复制标准模板{{else}}新建空白模板{{end}}</span></h2>
<div class="form-hint">
{{if eq .TemplateCreateMode "clone"}}复制后会保留原有节点和连线结构,再进入可视化编辑。{{else}}空白模板会打开空白画布,不包含任何预置处理链。{{end}}
</div>
</div>
</div>
<form method="post" action="/ui/assets/templates/create" class="inline-form">
<input type="hidden" name="clone_source" value="{{.TemplateCloneSource}}" />
<label>
<span>模板名称</span>
<input name="name" value="{{.TemplateDraftName}}" placeholder="例如 std_workshop_face_recognition_shoe_alarm_copy" />
</label>
<label class="grow">
<span>模板描述</span>
<input name="description" value="{{.TemplateDraftDescription}}" placeholder="说明这个模板用于什么业务流程" />
</label>
<button type="submit" class="btn secondary">{{icon "apply"}}<span>{{if eq .TemplateCreateMode "clone"}}复制并编辑{{else}}创建并编辑{{end}}</span></button>
</form>
</div>
{{end}}
{{if .AssetTemplate}}
<div class="card">
<div class="section-title">
@ -39,16 +68,40 @@
<h2 class="title-with-icon">{{icon "template"}}<span>模板详情</span></h2>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/assets/templates/{{.AssetTemplate.Name}}/graph">{{icon "assets"}}<span>{{if .AssetTemplate.ReadOnly}}可视化预览{{else}}可视化编辑{{end}}</span></a>
<a class="btn ghost" href="/ui/assets/templates?clone={{.AssetTemplate.Name}}">{{icon "add"}}<span>{{if .AssetTemplate.ReadOnly}}复制标准模板{{else}}复制模板{{end}}</span></a>
<button
type="button"
class="btn secondary js-export-json"
data-export-url="/ui/assets/templates/{{.AssetTemplate.Name}}/export"
data-default-filename="{{.AssetTemplate.Name}}.json"
>{{icon "apply"}}<span>另存为 JSON</span></button>
{{if not .AssetTemplate.ReadOnly}}
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/delete" onsubmit="return confirm('确认删除这个用户模板吗?');">
<button type="submit" class="btn danger">{{icon "close"}}<span>删除模板</span></button>
</form>
{{end}}
</div>
</div>
<div class="info-list">
<div><span>模板名</span><strong class="mono">{{.AssetTemplate.Name}}</strong></div>
<div>
<span>模板名</span>
{{if .AssetTemplate.ReadOnly}}
<strong class="mono">{{.AssetTemplate.Name}}</strong>
{{else}}
<div class="editable-line">
<strong class="mono js-inline-edit-display">{{.AssetTemplate.Name}}</strong>
<button type="button" class="btn ghost icon-only js-inline-edit-toggle" data-target="template-name-edit" aria-label="修改模板名" title="修改模板名">{{icon "edit"}}</button>
</div>
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/rename" id="template-name-edit" class="inline-edit-form" hidden>
<input type="hidden" name="description" value="{{.AssetTemplate.Description}}" />
<input name="name" value="{{.AssetTemplate.Name}}" class="mono" />
<button type="submit" class="btn secondary icon-only" aria-label="保存模板名" title="保存模板名">{{icon "apply"}}</button>
<button type="button" class="btn ghost icon-only js-inline-edit-cancel" data-target="template-name-edit" aria-label="取消" title="取消">{{icon "close"}}</button>
</form>
{{end}}
</div>
<div><span>模板类型</span><strong>{{if .AssetTemplate.ReadOnly}}标准模板(只读){{else}}用户模板{{end}}</strong></div>
<div><span>来源文件</span><strong class="mono">{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}</strong></div>
<div><span>节点数</span><strong>{{.AssetTemplate.NodeCount}}</strong></div>
<div><span>连线数</span><strong>{{.AssetTemplate.EdgeCount}}</strong></div>
@ -57,11 +110,33 @@
<div class="full"><span>取 token 接口</span><strong class="mono">{{if .AssetTemplate.ExternalGetTokenURL}}{{.AssetTemplate.ExternalGetTokenURL}}{{else}}-{{end}}</strong></div>
<div class="full"><span>告警上报接口</span><strong class="mono">{{if .AssetTemplate.ExternalPutMessageURL}}{{.AssetTemplate.ExternalPutMessageURL}}{{else}}-{{end}}</strong></div>
<div><span>租户编码</span><strong>{{if .AssetTemplate.TenantCode}}{{.AssetTemplate.TenantCode}}{{else}}-{{end}}</strong></div>
<div class="full"><span>描述</span><strong>{{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}}</strong></div>
<div class="full">
<span>描述</span>
{{if .AssetTemplate.ReadOnly}}
<strong>{{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}}</strong>
{{else}}
<div class="editable-line">
<strong class="js-inline-edit-display">{{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}}</strong>
<button type="button" class="btn ghost icon-only js-inline-edit-toggle" data-target="template-description-edit" aria-label="修改描述" title="修改描述">{{icon "edit"}}</button>
</div>
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/rename" id="template-description-edit" class="inline-edit-form" hidden>
<input type="hidden" name="name" value="{{.AssetTemplate.Name}}" />
<input name="description" value="{{.AssetTemplate.Description}}" />
<button type="submit" class="btn secondary icon-only" aria-label="保存描述" title="保存描述">{{icon "apply"}}</button>
<button type="button" class="btn ghost icon-only js-inline-edit-cancel" data-target="template-description-edit" aria-label="取消" title="取消">{{icon "close"}}</button>
</form>
{{end}}
</div>
<div class="full"><span>路径</span><strong class="mono">{{.AssetTemplate.Path}}</strong></div>
</div>
</div>
{{if .AssetTemplate.ReadOnly}}
<div class="card">
<div class="form-hint">标准模板保留在模板目录中,作为只读基线使用。需要调整流程时,请先复制为用户模板,再进入可视化编辑保存。</div>
</div>
{{end}}
{{if .AssetTemplate.AdvancedParams}}
<details class="card collapsible">
<summary class="title-with-icon">{{icon "tech"}}<span>高级设置</span></summary>

View File

@ -73,7 +73,7 @@
{{range .AssetTemplates}}
<a class="asset-row asset-link" href="/ui/assets/templates/{{.Name}}">
<span class="mono">{{.Name}}</span>
<span class="muted small">{{.NodeCount}} 节点 / {{.EdgeCount}} 连线</span>
<span class="muted small">{{.NodeCount}}节点 / {{.EdgeCount}}连线</span>
</a>
{{else}}
<div class="empty-state compact">

View File

@ -7,6 +7,7 @@
<title>{{.Title}}</title>
<link rel="stylesheet" href="/ui/assets/vendor/tabler.min.css" />
<link rel="stylesheet" href="/ui/assets/style.css" />
<link rel="stylesheet" href="/ui/assets/graph_editor.css" />
</head>
<body class="theme-light">
<div class="app-shell">
@ -110,13 +111,46 @@
return;
}
const dbBtn = event.target.closest(".js-export-db");
if (!dbBtn) return;
event.preventDefault();
const url = dbBtn.getAttribute("data-export-url");
const filename = dbBtn.getAttribute("data-default-filename");
saveDBFromURL(url, filename).catch(function (err) {
window.alert(err && err.message ? err.message : "备份失败");
});
if (dbBtn) {
event.preventDefault();
const url = dbBtn.getAttribute("data-export-url");
const filename = dbBtn.getAttribute("data-default-filename");
saveDBFromURL(url, filename).catch(function (err) {
window.alert(err && err.message ? err.message : "备份失败");
});
return;
}
const editToggle = event.target.closest(".js-inline-edit-toggle");
if (editToggle) {
event.preventDefault();
const targetId = editToggle.getAttribute("data-target");
const form = targetId ? document.getElementById(targetId) : null;
const display = editToggle.closest(".editable-line");
if (form) {
form.hidden = false;
if (display) display.hidden = true;
const input = form.querySelector("input:not([type=hidden])");
if (input) {
input.focus();
input.select();
}
}
return;
}
const editCancel = event.target.closest(".js-inline-edit-cancel");
if (editCancel) {
event.preventDefault();
const targetId = editCancel.getAttribute("data-target");
const form = targetId ? document.getElementById(targetId) : null;
if (form) {
form.hidden = true;
const container = form.parentElement;
const display = container ? container.querySelector(".editable-line") : null;
if (display) display.hidden = false;
}
}
});
})();
</script>

View File

@ -268,7 +268,7 @@ func TestUI_DeviceBatchConfigPageShowsSelectedSummaryAndSources(t *testing.T) {
"业务配置摘要",
"local_3588_test",
"A厂区视觉识别",
"workshop_face_shoe_alarm",
"std_workshop_face_recognition_shoe_alarm",
"东门入口",
} {
if !strings.Contains(body, want) {
@ -336,7 +336,7 @@ func TestUI_ActionDeviceBatchConfigCreatesTaskAndRedirects(t *testing.T) {
if !ok {
t.Fatalf("expected metadata object, got %#v", configDoc["metadata"])
}
if metadata["template"] != "workshop_face_shoe_alarm" {
if metadata["template"] != "std_workshop_face_recognition_shoe_alarm" {
t.Fatalf("expected template metadata, got %#v", metadata["template"])
}
if metadata["profile"] != "local_3588_test" {
@ -352,7 +352,7 @@ func TestUI_ActionDeviceBatchConfigRenderFailurePreservesUserInput(t *testing.T)
form := url.Values{}
form.Add("device_id", "edge-01")
form.Add("device_id", "edge-02")
form.Set("template", "workshop_face_shoe_alarm")
form.Set("template", "std_workshop_face_recognition_shoe_alarm")
form.Set("profile", "local_3588_test")
form.Set("config_id", "")
form.Set("config_version", "")
@ -465,7 +465,7 @@ func TestUI_ActionAssetProfileSaveWritesProfileFile(t *testing.T) {
form.Set("description", "updated profile")
form.Set("site_name", "B厂区")
form.Set("instances[0].name", "cam1")
form.Set("instances[0].template", "workshop_face_shoe_alarm")
form.Set("instances[0].template", "std_workshop_face_recognition_shoe_alarm")
form.Set("instances[0].display_name", "西门入口")
form.Set("instances[0].channel_no", "cam1")
form.Set("instances[0].rtsp_url", "rtsp://10.0.0.2/live")
@ -474,7 +474,7 @@ func TestUI_ActionAssetProfileSaveWritesProfileFile(t *testing.T) {
form.Set("instances[0].publish_rtsp_path", "/live/cam1")
form.Set("instances[0].advanced_params", `{"queue_debug":true}`)
form.Set("instances[1].name", "cam2")
form.Set("instances[1].template", "workshop_face_shoe_alarm")
form.Set("instances[1].template", "std_workshop_face_recognition_shoe_alarm")
form.Set("instances[1].display_name", "视觉识别终端-C厂区")
form.Set("instances[1].channel_no", "cam2")
form.Set("instances[1].rtsp_url", "rtsp://10.0.0.3/live")
@ -540,8 +540,8 @@ func TestUI_ActionAssetProfileSaveWritesProfileFile(t *testing.T) {
func createBatchConfigMediaRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{
"name":"workshop_face_shoe_alarm",
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{
"name":"std_workshop_face_recognition_shoe_alarm",
"description":"helmet and shoe alarm",
"template":{"nodes":[],"edges":[]}
}`)
@ -552,7 +552,7 @@ func createBatchConfigMediaRepo(t *testing.T) string {
"instances":[
{
"name":"cam1",
"template":"workshop_face_shoe_alarm",
"template":"std_workshop_face_recognition_shoe_alarm",
"params":{
"display_name":"东门入口",
"site_name":"A厂区",
@ -598,8 +598,8 @@ with open(args.out, "w", encoding="utf-8") as fh:
func createProfileEditorMediaRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{
"name": "workshop_face_shoe_alarm",
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{
"name": "std_workshop_face_recognition_shoe_alarm",
"template": {"nodes": [], "edges": []}
}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
@ -610,7 +610,7 @@ func createProfileEditorMediaRepo(t *testing.T) string {
"instances": [
{
"name": "cam1",
"template": "workshop_face_shoe_alarm",
"template": "std_workshop_face_recognition_shoe_alarm",
"params": {
"display_name": "东门入口",
"device_code": "rk3588-a-001",
@ -625,7 +625,7 @@ func createProfileEditorMediaRepo(t *testing.T) string {
},
{
"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",
@ -637,7 +637,7 @@ func createProfileEditorMediaRepo(t *testing.T) string {
},
{
"name": "cam3",
"template": "workshop_face_shoe_alarm",
"template": "std_workshop_face_recognition_shoe_alarm",
"params": {
"display_name": "1号产线",
"rtsp_url": "rtsp://10.0.0.3/live",
@ -649,7 +649,7 @@ func createProfileEditorMediaRepo(t *testing.T) string {
},
{
"name": "cam4",
"template": "workshop_face_shoe_alarm",
"template": "std_workshop_face_recognition_shoe_alarm",
"params": {
"display_name": "2号产线",
"rtsp_url": "rtsp://10.0.0.4/live",
@ -674,11 +674,11 @@ func withChiURLParam(req *http.Request, key string, value string) *http.Request
func createBatchConfigBrokenMediaRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
"name":"local_3588_test",
"business_name":"A厂区视觉识别",
"instances":[{"name":"cam1","template":"workshop_face_shoe_alarm","params":{"display_name":"东门入口"}}]
"instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","params":{"display_name":"东门入口"}}]
}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`)
return root
@ -702,7 +702,7 @@ func TestUI_TaskPageRendersBatchSummaryAndDeviceResults(t *testing.T) {
form := url.Values{}
form.Add("device_id", "edge-01")
form.Add("device_id", "edge-02")
form.Set("template", "workshop_face_shoe_alarm")
form.Set("template", "std_workshop_face_recognition_shoe_alarm")
form.Set("profile", "local_3588_test")
form.Set("config_id", "batch_edge")
form.Set("config_version", "20260420.090000")
@ -806,7 +806,7 @@ func TestUI_DeviceOverviewShowsLiveStatusAndConfigSummary(t *testing.T) {
"metadata": {
"config_id": "local_3588_face_debug",
"config_version": "20260419.104457",
"template": "workshop_face_shoe_alarm",
"template": "std_workshop_face_recognition_shoe_alarm",
"profile": "local_3588_test",
"overlays": ["face_debug", "production_quiet"]
},
@ -989,7 +989,7 @@ func TestUI_DeviceDetailShowsRunningConfigMetadata(t *testing.T) {
"metadata": {
"config_id": "local_3588_face_debug",
"config_version": "20260419.104457",
"template": "workshop_face_shoe_alarm",
"template": "std_workshop_face_recognition_shoe_alarm",
"profile": "local_3588_test",
"overlays": ["face_debug"],
"rendered_by": "tools/render_config.py",
@ -1034,7 +1034,7 @@ func TestUI_DeviceDetailShowsRunningConfigMetadata(t *testing.T) {
"当前运行配置",
"local_3588_face_debug",
"20260419.104457",
"workshop_face_shoe_alarm",
"std_workshop_face_recognition_shoe_alarm",
"local_3588_test",
"face_debug",
"8d935c9d",
@ -1174,7 +1174,7 @@ func TestUI_ConfigPreviewPageShowsTemplateProfileOverlayForm(t *testing.T) {
"config_id",
"config_version",
"生成预览",
"workshop_face_shoe_alarm",
"std_workshop_face_recognition_shoe_alarm",
"local_3588_test",
"face_debug",
} {
@ -1210,7 +1210,7 @@ func TestUI_ConfigPreviewPageKeepsApplyActionAfterUploadResult(t *testing.T) {
Metadata: map[string]any{
"config_id": "preview_edge-01",
"config_version": "v1",
"template": "workshop_face_shoe_alarm",
"template": "std_workshop_face_recognition_shoe_alarm",
"profile": "local_3588_test",
},
Size: 123,
@ -1270,7 +1270,7 @@ func TestUI_ActionDeviceConfigCandidateKeepsPreviewApplyAction(t *testing.T) {
}
form := url.Values{}
form.Set("json", `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1","template":"workshop_face_shoe_alarm","profile":"local_3588_test"}}`)
form.Set("json", `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1","template":"std_workshop_face_recognition_shoe_alarm","profile":"local_3588_test"}}`)
req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/config-candidate", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("id", "edge-01")
@ -1301,17 +1301,17 @@ func TestUI_ConfigPreviewKeepsSelectedOverlayAfterPreview(t *testing.T) {
ui.render(rr, req, "config_preview", PageData{
Title: "配置预览",
Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100},
ConfigSources: service.ConfigPreviewSources{Templates: []service.ConfigSource{{Name: "workshop_face_shoe_alarm"}}, Profiles: []service.ConfigSource{{Name: "local_3588_test"}}, Overlays: []service.ConfigSource{{Name: "face_debug"}, {Name: "face_test_sensitive"}}},
SelectedTemplate: "workshop_face_shoe_alarm",
ConfigSources: service.ConfigPreviewSources{Templates: []service.ConfigSource{{Name: "std_workshop_face_recognition_shoe_alarm"}}, Profiles: []service.ConfigSource{{Name: "local_3588_test"}}, Overlays: []service.ConfigSource{{Name: "face_debug"}, {Name: "face_test_sensitive"}}},
SelectedTemplate: "std_workshop_face_recognition_shoe_alarm",
SelectedProfile: "local_3588_test",
SelectedOverlays: []string{"face_test_sensitive"},
SelectedConfigID: "preview_edge-01",
ConfigPreview: &service.ConfigPreviewResult{
JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1","template":"workshop_face_shoe_alarm","profile":"local_3588_test","overlays":["face_test_sensitive"]}}`,
JSON: `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v1","template":"std_workshop_face_recognition_shoe_alarm","profile":"local_3588_test","overlays":["face_test_sensitive"]}}`,
Metadata: map[string]any{
"config_id": "preview_edge-01",
"config_version": "v1",
"template": "workshop_face_shoe_alarm",
"template": "std_workshop_face_recognition_shoe_alarm",
"profile": "local_3588_test",
"overlays": []any{"face_test_sensitive"},
},
@ -1336,8 +1336,8 @@ func TestUI_ConfigPreviewCollapsesJSONAfterActionResult(t *testing.T) {
ui.render(rr, req, "config_preview", PageData{
Title: "配置预览",
Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100},
ConfigSources: service.ConfigPreviewSources{Templates: []service.ConfigSource{{Name: "workshop_face_shoe_alarm"}}, Profiles: []service.ConfigSource{{Name: "local_3588_test"}}, Overlays: []service.ConfigSource{{Name: "face_debug"}}},
SelectedTemplate: "workshop_face_shoe_alarm",
ConfigSources: service.ConfigPreviewSources{Templates: []service.ConfigSource{{Name: "std_workshop_face_recognition_shoe_alarm"}}, Profiles: []service.ConfigSource{{Name: "local_3588_test"}}, Overlays: []service.ConfigSource{{Name: "face_debug"}}},
SelectedTemplate: "std_workshop_face_recognition_shoe_alarm",
SelectedProfile: "local_3588_test",
SelectedOverlays: []string{"face_debug"},
SelectedConfigID: "preview_edge-01",
@ -1367,7 +1367,7 @@ func TestUI_ConfigPreviewShowsApplySummaryAfterApplyResult(t *testing.T) {
ui.render(rr, req, "config_preview", PageData{
Title: "配置预览",
Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100},
SelectedTemplate: "workshop_face_shoe_alarm",
SelectedTemplate: "std_workshop_face_recognition_shoe_alarm",
SelectedProfile: "local_3588_test",
SelectedOverlays: []string{"face_debug"},
SelectedConfigID: "preview_edge-01",
@ -1441,7 +1441,7 @@ func TestUI_ConfigPreviewExplainsConfigIdentityFields(t *testing.T) {
ui.render(rr, req, "config_preview", PageData{
Title: "配置预览",
Device: &models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100},
SelectedTemplate: "workshop_face_shoe_alarm",
SelectedTemplate: "std_workshop_face_recognition_shoe_alarm",
SelectedProfile: "local_3588_test",
SelectedOverlays: []string{"face_debug"},
SelectedConfigID: "preview_edge-01",
@ -1450,7 +1450,7 @@ func TestUI_ConfigPreviewExplainsConfigIdentityFields(t *testing.T) {
Metadata: map[string]any{
"config_id": "preview_edge-01",
"config_version": "v2",
"template": "workshop_face_shoe_alarm",
"template": "std_workshop_face_recognition_shoe_alarm",
"profile": "local_3588_test",
"overlays": []any{"face_debug"},
},
@ -1537,7 +1537,7 @@ func TestUI_ActionDeviceConfigCandidateApplyReloadsStatusAfterApply(t *testing.T
}
form := url.Values{}
form.Set("json", `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v2","template":"workshop_face_shoe_alarm","profile":"local_3588_test"}}`)
form.Set("json", `{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"preview_edge-01","config_version":"v2","template":"std_workshop_face_recognition_shoe_alarm","profile":"local_3588_test"}}`)
req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/config-candidate/apply", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("id", "edge-01")
@ -1858,8 +1858,8 @@ func TestUI_ActionDeviceConfigCandidateApplyCanRenderControlPage(t *testing.T) {
func TestUI_AssetsPageDefinesConfigAssetScope(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_shoe_alarm","description":"template","params":{"minio_endpoint":"http://10.0.0.49:9000"},"template":{"nodes":[{},{}],"edges":[[]]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"workshop_face_shoe_alarm","params":{"display_name":"东门入口"}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","description":"template","params":{"minio_endpoint":"http://10.0.0.49:9000"},"template":{"nodes":[{},{}],"edges":[[]]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","params":{"display_name":"东门入口"}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root})
req := httptest.NewRequest(http.MethodGet, "/ui/assets", nil)
@ -1868,7 +1868,7 @@ func TestUI_AssetsPageDefinesConfigAssetScope(t *testing.T) {
ui.pageAssets(rr, req)
body := rr.Body.String()
for _, want := range []string{"识别配置", "总览", "模板", "业务配置", "叠加项", "local_3588_test", "workshop_face_shoe_alarm", "face_debug"} {
for _, want := range []string{"识别配置", "总览", "模板", "业务配置", "叠加项", "local_3588_test", "std_workshop_face_recognition_shoe_alarm", "face_debug"} {
if !strings.Contains(body, want) {
t.Fatalf("expected assets HTML to contain %q", want)
}
@ -1878,7 +1878,7 @@ func TestUI_AssetsPageDefinesConfigAssetScope(t *testing.T) {
func TestUI_ProfileAssetPageShowsInstanceSummary(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_shoe_alarm","params":{"minio_endpoint":"http://10.0.0.49:9000","external_get_token_url":"http://10.0.0.49:8080/api/getToken"},"template":{"nodes":[{}],"edges":[]}}`)
writeTestFile(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","external_get_token_url":"http://10.0.0.49:8080/api/getToken"},"template":{"nodes":[{}],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
"name":"local_3588_test",
"business_name":"A厂区视觉识别",
@ -1886,7 +1886,7 @@ func TestUI_ProfileAssetPageShowsInstanceSummary(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",
@ -1929,9 +1929,9 @@ func TestUI_ProfileAssetPageShowsInstanceSummary(t *testing.T) {
func TestUI_TemplateAssetsPageShowsListAndSelectedDetail(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_shoe_alarm","description":"template","params":{"minio_endpoint":"http://10.0.0.49:9000"},"template":{"nodes":[{},{}],"edges":[[]]}}`)
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","description":"template","params":{"minio_endpoint":"http://10.0.0.49:9000"},"template":{"nodes":[{},{}],"edges":[[]]}}`)
writeTestFile(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","description":"helmet template","template":{"nodes":[{}],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"workshop_face_shoe_alarm","params":{"display_name":"东门入口"}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","business_name":"A厂区视觉识别","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","params":{"display_name":"东门入口"}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root})
@ -1941,7 +1941,7 @@ func TestUI_TemplateAssetsPageShowsListAndSelectedDetail(t *testing.T) {
ui.pageAssetTemplates(rr, req)
body := rr.Body.String()
for _, want := range []string{"模板列表", "workshop_face_shoe_alarm", "helmet", "模板详情", "helmet template"} {
for _, want := range []string{"模板列表", "std_workshop_face_recognition_shoe_alarm", "helmet", "模板详情", "helmet template"} {
if !strings.Contains(body, want) {
t.Fatalf("expected template assets page to contain %q, got:\n%s", want, body)
}
@ -1951,8 +1951,8 @@ func TestUI_TemplateAssetsPageShowsListAndSelectedDetail(t *testing.T) {
func TestUI_OverlayAssetsPageShowsListAndSelectedDetail(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_shoe_alarm","template":{"nodes":[{}],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","instances":[{"name":"cam1","template":"workshop_face_shoe_alarm","params":{"display_name":"东门入口"}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[{}],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"local_3588_test","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","params":{"display_name":"东门入口"}}]}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"description":"relaxed","instance_overrides":{"cam2":{"override":{}}}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root})
@ -1973,20 +1973,20 @@ func TestUI_OverlayAssetsPageShowsListAndSelectedDetail(t *testing.T) {
func TestUI_ProfileAssetsPageShowsListAndSelectedEditor(t *testing.T) {
ui := newTestUI(t)
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"workshop_face_shoe_alarm","template":{"nodes":[{}],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{"name":"std_workshop_face_recognition_shoe_alarm","template":{"nodes":[{}],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
"name":"local_3588_test",
"business_name":"A厂区视觉识别",
"description":"test profile",
"queue":{"size":8,"strategy":"drop_oldest"},
"instances":[{"name":"cam1","template":"workshop_face_shoe_alarm","params":{"display_name":"东门入口","rtsp_url":"rtsp://10.0.0.1/live"}}]
"instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","params":{"display_name":"东门入口","rtsp_url":"rtsp://10.0.0.1/live"}}]
}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "night_shift.json"), `{
"name":"night_shift",
"business_name":"夜班巡检",
"description":"night profile",
"queue":{"size":4,"strategy":"drop_oldest"},
"instances":[{"name":"cam9","template":"workshop_face_shoe_alarm","params":{"display_name":"西门","rtsp_url":"rtsp://10.0.0.9/live"}}]
"instances":[{"name":"cam9","template":"std_workshop_face_recognition_shoe_alarm","params":{"display_name":"西门","rtsp_url":"rtsp://10.0.0.9/live"}}]
}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"description":"debug","instance_overrides":{"*":{"override":{}}}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root})
@ -2370,6 +2370,519 @@ func TestUI_AssetTemplateShowsSaveAsExportButton(t *testing.T) {
}
}
func TestUI_AssetTemplatesPagePromotesCloneFlow(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":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
ui := newTestUI(t)
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
html := renderPage(t, ui, "/ui/assets/templates?name=helmet&clone=helmet")
for _, want := range []string{"标准模板应作为基线", "复制标准模板", "复制模板", `name="clone_source" value="helmet"`, "helmet_copy", "复制并编辑"} {
if !strings.Contains(html, want) {
t.Fatalf("expected template page to contain %q, got: %s", want, html)
}
}
}
func TestUI_AssetTemplateGraphPageRendersEditorShell(t *testing.T) {
ui := newTestUI(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": [{"id":"in","type":"input_rtsp","role":"source","enable":true}],
"edges": []
}
}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
html := renderPage(t, ui, "/ui/assets/templates/helmet/graph")
for _, want := range []string{
"模板可视化编辑",
"graph-editor",
"graph_editor.css",
"graph_editor.js",
"graph-template-json",
`"id":"in"`,
`"type":"input_rtsp"`,
"graph-node-form",
`name="id"`,
`name="role"`,
`name="enable"`,
"graph-connect-target",
"graph-typed-param-fields",
"graph-auto-layout",
"graph-param-editor",
"graph-edge-form",
"graph-node-palette-list",
`data-catalog-url="/ui/api/graph-node-types"`,
`name="from"`,
`name="to"`,
"常用参数",
"高级 JSON",
"自动布局",
} {
if !strings.Contains(html, want) {
t.Fatalf("expected graph editor page to contain %q, got: %s", want, html)
}
}
}
func TestUI_AssetTemplatePageShowsRenameAndDeleteForUserTemplate(t *testing.T) {
ui := newTestUI(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)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
html := renderPage(t, ui, "/ui/assets/templates?name=helmet")
for _, want := range []string{`action="/ui/assets/templates/helmet/rename"`, `action="/ui/assets/templates/helmet/delete"`, "js-inline-edit-toggle", "template-name-edit", "template-description-edit"} {
if !strings.Contains(html, want) {
t.Fatalf("expected template detail to contain %q, got: %s", want, html)
}
}
}
func TestUI_ActionAssetTemplateCreateClonesSourceTemplate(t *testing.T) {
ui := newTestUI(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":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[["in","det"]]},"extra":{"mode":"std"}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
form := url.Values{}
form.Set("name", "helmet_copy")
form.Set("description", "copy")
form.Set("clone_source", "helmet")
req := httptest.NewRequest(http.MethodPost, "/assets/templates/create", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates/helmet_copy/graph") {
t.Fatalf("expected graph redirect, got %q", got)
}
saved, err := repo.GetTemplate("helmet_copy")
if err != nil {
t.Fatalf("GetTemplate: %v", err)
}
for _, want := range []string{`"name": "helmet_copy"`, `"description": "copy"`, `"type": "input_rtsp"`, `"mode": "std"`} {
if saved == nil || !strings.Contains(saved.BodyJSON, want) {
t.Fatalf("expected cloned template to contain %q, got %#v", want, saved)
}
}
}
func TestUI_ActionAssetTemplateCreateBuildsBlankTemplate(t *testing.T) {
ui := newTestUI(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())
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
form := url.Values{}
form.Set("name", "blank_flow")
form.Set("description", "blank")
req := httptest.NewRequest(http.MethodPost, "/assets/templates/create", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
saved, err := repo.GetTemplate("blank_flow")
if err != nil {
t.Fatalf("GetTemplate: %v", err)
}
for _, want := range []string{`"name": "blank_flow"`, `"description": "blank"`, `"nodes": []`, `"edges": []`} {
if saved == nil || !strings.Contains(saved.BodyJSON, want) {
t.Fatalf("expected blank template to contain %q, got %#v", want, saved)
}
}
}
func TestUI_GraphNodeTypesAPIFallbackListsCatalog(t *testing.T) {
ui := newTestUI(t)
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/graph-node-types", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var payload struct {
Items []graphNodeTypeInfo `json:"items"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil {
t.Fatalf("invalid json: %v", err)
}
got := map[string]graphNodeTypeInfo{}
for _, item := range payload.Items {
got[item.Type] = item
}
for _, want := range []string{"input_rtsp", "ai_shoe_det", "event_fusion", "zlm_http"} {
item, ok := got[want]
if !ok {
t.Fatalf("expected graph node type %q in %#v", want, payload.Items)
}
if item.Label == "" || item.Icon == "" || item.Description == "" {
t.Fatalf("expected %q to include label, icon and description: %#v", want, item)
}
if item.Defaults["type"] != want {
t.Fatalf("expected %q defaults.type, got %#v", want, item.Defaults)
}
}
}
func TestUI_AssetTemplateGraphSavePersistsNodeAndEdgeParameters(t *testing.T) {
ui := newTestUI(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)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
form := url.Values{}
form.Set("json", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source","enable":true,"url":"${rtsp_url}"},{"id":"det","type":"ai_yolo","role":"filter","enable":true,"conf":0.42}],"edges":[{"from":"in","to":"det","stream":"video"}]}}`)
req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
saved, err := repo.GetTemplate("helmet")
if err != nil {
t.Fatalf("GetTemplate: %v", err)
}
for _, want := range []string{`"url": "${rtsp_url}"`, `"conf": 0.42`, `"stream": "video"`} {
if saved == nil || !strings.Contains(saved.BodyJSON, want) {
t.Fatalf("expected saved graph parameter %q, got %#v", want, saved)
}
}
}
func TestUI_AssetTemplateGraphSavePersistsLayout(t *testing.T) {
ui := newTestUI(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":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
form := url.Values{}
form.Set("json", `{"name":"helmet","description":"helmet template","ui":{"layout":{"version":1,"nodes":{"in":{"x":123,"y":456}}}},"template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`)
req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
saved, err := repo.GetTemplate("helmet")
if err != nil {
t.Fatalf("GetTemplate: %v", err)
}
if saved == nil || !strings.Contains(saved.BodyJSON, `"x": 123`) || !strings.Contains(saved.BodyJSON, `"y": 456`) {
t.Fatalf("expected saved layout, got %#v", saved)
}
}
func TestUI_AssetTemplateGraphSaveCanRenameTemplate(t *testing.T) {
ui := newTestUI(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":[{"id":"in","type":"input_rtsp","role":"source"}],"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)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
form := url.Values{}
form.Set("name", "helmet_v2")
form.Set("description", "helmet v2")
form.Set("json", `{"name":"helmet","description":"helmet template","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[]}}`)
req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates/helmet_v2/graph") {
t.Fatalf("expected renamed graph redirect, got %q", got)
}
oldRecord, err := repo.GetTemplate("helmet")
if err != nil {
t.Fatalf("GetTemplate old: %v", err)
}
if oldRecord != nil {
t.Fatalf("expected old template removed, got %#v", oldRecord)
}
newRecord, err := repo.GetTemplate("helmet_v2")
if err != nil {
t.Fatalf("GetTemplate new: %v", err)
}
if newRecord == nil || !strings.Contains(newRecord.BodyJSON, `"name": "helmet_v2"`) {
t.Fatalf("expected renamed template saved, got %#v", newRecord)
}
profile, err := repo.GetProfile("gate_a")
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
if profile == nil || profile.TemplateName != "helmet_v2" {
t.Fatalf("expected profile ref updated, got %#v", profile)
}
}
func TestUI_ActionAssetTemplateRenameFromDetailPage(t *testing.T) {
ui := newTestUI(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)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
form := url.Values{}
form.Set("name", "helmet_v2")
form.Set("description", "helmet v2")
req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/rename", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates?name=helmet_v2") {
t.Fatalf("expected detail redirect, got %q", got)
}
record, err := repo.GetTemplate("helmet_v2")
if err != nil {
t.Fatalf("GetTemplate: %v", err)
}
if record == nil || record.Description != "helmet_v2" && record.Description != "helmet v2" {
t.Fatalf("expected renamed template, got %#v", record)
}
}
func TestUI_ActionAssetTemplateDeleteFromDetailPage(t *testing.T) {
ui := newTestUI(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)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/delete", nil)
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Location"); !strings.Contains(got, "/ui/assets/templates?msg=") {
t.Fatalf("expected list redirect with message, got %q", got)
}
record, err := repo.GetTemplate("helmet")
if err != nil {
t.Fatalf("GetTemplate: %v", err)
}
if record != nil {
t.Fatalf("expected deleted template, got %#v", record)
}
}
func TestUI_AssetTemplateGraphSaveRejectsUnknownEdgeEndpoint(t *testing.T) {
ui := newTestUI(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)
}
ui.preview = service.NewConfigPreviewService(&config.Config{}, repo)
form := url.Values{}
form.Set("json", `{"name":"helmet","template":{"nodes":[{"id":"in","type":"input_rtsp","role":"source"}],"edges":[["in","missing"]]}}`)
req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "edge references unknown node") {
t.Fatalf("expected edge validation error, got: %s", rr.Body.String())
}
}
func TestUI_AssetTemplatePageMarksBuiltinTemplateReadonly(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","description":"builtin helmet","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
ui := newTestUI(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", "shadow helmet", `{"name":"helmet","description":"shadow helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
html := renderPage(t, ui, "/ui/assets/templates?name=helmet")
for _, want := range []string{"标准模板(只读)", "复制标准模板", "可视化预览", "builtin helmet"} {
if !strings.Contains(html, want) {
t.Fatalf("expected builtin template page to contain %q, got: %s", want, html)
}
}
if strings.Contains(html, "shadow helmet") {
t.Fatalf("expected builtin template to hide sqlite shadow copy, got: %s", html)
}
}
func TestUI_AssetTemplateGraphSaveRejectsBuiltinTemplate(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","description":"builtin helmet","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`)
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`)
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
ui := newTestUI(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, storage.NewAssetsRepo(store.DB()))
form := url.Values{}
form.Set("json", `{"name":"helmet","description":"builtin helmet","template":{"nodes":[{"id":"input_rtsp_main","type":"input_rtsp","role":"source"}],"edges":[]}}`)
req := httptest.NewRequest(http.MethodPost, "/assets/templates/helmet/graph", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
router, err := ui.Routes()
if err != nil {
t.Fatalf("Routes: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "read-only") {
t.Fatalf("expected readonly rejection, got: %s", rr.Body.String())
}
}
func TestUI_AuditPagePrefersPersistedAuditLogs(t *testing.T) {
ui := newTestUI(t)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))

20
package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "3588adminbackend",
"version": "1.0.0",
"description": "`managerd` 是 RK3588 管理端后端服务,负责设备发现、设备注册表维护、代理访问设备端 `rk3588-agent`,并提供内嵌 Web UI、OpenAPI 页面和 HTTP API。",
"main": "index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "http://10.0.0.99:4000/Doni/3588AdminBackend.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs"
}