Add visual template editor and refine template management
This commit is contained in:
parent
28ef9c856e
commit
d712e5628b
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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},
|
||||
} {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
159
internal/web/graph_node_types.go
Normal file
159
internal/web/graph_node_types.go
Normal 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
|
||||
}
|
||||
@ -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) == "" {
|
||||
|
||||
38
internal/web/ui/assets/graph_editor.css
Normal file
38
internal/web/ui/assets/graph_editor.css
Normal 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}
|
||||
806
internal/web/ui/assets/graph_editor.js
Normal file
806
internal/web/ui/assets/graph_editor.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
@ -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}
|
||||
|
||||
76
internal/web/ui/templates/asset_template_graph.html
Normal file
76
internal/web/ui/templates/asset_template_graph.html
Normal 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}}
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
20
package.json
Normal 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"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user